Merge pull request #450 from beyondcode/refactor/tests

[2.x] Test Refactoring
This commit is contained in:
rennokki 2020-08-14 20:49:45 +03:00 committed by GitHub
commit 38b2e4d404
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 871 additions and 212 deletions

View File

@ -24,6 +24,12 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v1 uses: actions/checkout@v1
- name: Setup Redis
uses: supercharge/redis-github-action@1.1.0
with:
redis-version: 6
if: ${{ matrix.os == 'ubuntu-latest' }}
- name: Cache dependencies - name: Cache dependencies
uses: actions/cache@v1 uses: actions/cache@v1
with: with:
@ -35,16 +41,25 @@ jobs:
with: with:
php-version: ${{ matrix.php }} php-version: ${{ matrix.php }}
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick
coverage: pcov coverage: xdebug
- name: Install dependencies - name: Install dependencies
run: | run: |
composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update
composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction --no-suggest composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction --no-suggest
- name: Execute tests - name: Execute tests with Local driver
run: vendor/bin/phpunit --coverage-text --coverage-clover=coverage.xml run: vendor/bin/phpunit --coverage-text --coverage-clover=coverage_local.xml
env:
REPLICATION_DRIVER: local
- name: Execute tests with Redis driver
run: vendor/bin/phpunit --coverage-text --coverage-clover=coverage_redis.xml
if: ${{ matrix.os == 'ubuntu-latest' }}
env:
REPLICATION_DRIVER: redis
- uses: codecov/codecov-action@v1 - uses: codecov/codecov-action@v1
with: with:
fail_ci_if_error: false fail_ci_if_error: false
file: '*.xml'

View File

@ -143,23 +143,21 @@ return [
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Broadcasting Replication | Broadcasting Replication PubSub
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| You can enable replication to publish and subscribe to | You can enable replication to publish and subscribe to
| messages across the driver. | messages across the driver.
|
| By default, it is disabled, but you can configure it to use drivers | By default, it is set to 'local', but you can configure it to use drivers
| like Redis to ensure connection between multiple instances of | like Redis to ensure connection between multiple instances of
| WebSocket servers. | WebSocket servers. Just set the driver to 'redis' to enable the PubSub using Redis.
| |
*/ */
'replication' => [ 'replication' => [
'enabled' => false, 'driver' => 'local',
'driver' => 'redis',
'redis' => [ 'redis' => [

View File

@ -70,7 +70,6 @@
<thead> <thead>
<tr> <tr>
<th>Type</th> <th>Type</th>
<th>Socket</th>
<th>Details</th> <th>Details</th>
<th>Time</th> <th>Time</th>
</tr> </tr>
@ -78,8 +77,7 @@
<tbody> <tbody>
<tr v-for="log in logs.slice().reverse()"> <tr v-for="log in logs.slice().reverse()">
<td><span class="badge" :class="getBadgeClass(log)">@{{ log.type }}</span></td> <td><span class="badge" :class="getBadgeClass(log)">@{{ log.type }}</span></td>
<td>@{{ log.socketId }}</td> <td><pre>@{{ log.details }}</pre></td>
<td>@{{ log.details }}</td>
<td>@{{ log.time }}</td> <td>@{{ log.time }}</td>
</tr> </tr>
</tbody> </tbody>
@ -207,6 +205,8 @@
'subscribed', 'subscribed',
'client-message', 'client-message',
'api-message', 'api-message',
'replicator-subscribed',
'replicator-unsubscribed',
].forEach(channelName => this.subscribeToChannel(channelName)) ].forEach(channelName => this.subscribeToChannel(channelName))
}, },

View File

@ -4,6 +4,8 @@ namespace BeyondCode\LaravelWebSockets\Console;
use BeyondCode\LaravelWebSockets\Facades\StatisticsLogger; use BeyondCode\LaravelWebSockets\Facades\StatisticsLogger;
use BeyondCode\LaravelWebSockets\Facades\WebSocketsRouter; use BeyondCode\LaravelWebSockets\Facades\WebSocketsRouter;
use BeyondCode\LaravelWebSockets\PubSub\Drivers\LocalClient;
use BeyondCode\LaravelWebSockets\PubSub\Drivers\RedisClient;
use BeyondCode\LaravelWebSockets\PubSub\ReplicationInterface; use BeyondCode\LaravelWebSockets\PubSub\ReplicationInterface;
use BeyondCode\LaravelWebSockets\Server\Logger\ConnectionLogger; use BeyondCode\LaravelWebSockets\Server\Logger\ConnectionLogger;
use BeyondCode\LaravelWebSockets\Server\Logger\HttpLogger; use BeyondCode\LaravelWebSockets\Server\Logger\HttpLogger;
@ -23,7 +25,12 @@ use React\Socket\Connector;
class StartWebSocketServer extends Command class StartWebSocketServer extends Command
{ {
protected $signature = 'websockets:serve {--host=0.0.0.0} {--port=6001} {--debug : Forces the loggers to be enabled and thereby overriding the app.debug config setting } '; protected $signature = 'websockets:serve
{--host=0.0.0.0}
{--port=6001}
{--debug : Forces the loggers to be enabled and thereby overriding the APP_DEBUG setting.}
{--test : Prepare the server, but do not start it.}
';
protected $description = 'Start the Laravel WebSocket Server'; protected $description = 'Start the Laravel WebSocket Server';
@ -48,6 +55,7 @@ class StartWebSocketServer extends Command
->configureMessageLogger() ->configureMessageLogger()
->configureConnectionLogger() ->configureConnectionLogger()
->configureRestartTimer() ->configureRestartTimer()
->configurePubSub()
->registerEchoRoutes() ->registerEchoRoutes()
->registerCustomRoutes() ->registerCustomRoutes()
->configurePubSubReplication() ->configurePubSubReplication()
@ -66,7 +74,10 @@ class StartWebSocketServer extends Command
$this->laravel->singleton(StatisticsLoggerInterface::class, function () use ($browser) { $this->laravel->singleton(StatisticsLoggerInterface::class, function () use ($browser) {
$class = config('websockets.statistics.logger', \BeyondCode\LaravelWebSockets\Statistics\Logger\HttpStatisticsLogger::class); $class = config('websockets.statistics.logger', \BeyondCode\LaravelWebSockets\Statistics\Logger\HttpStatisticsLogger::class);
return new $class(app(ChannelManager::class), $browser); return new $class(
$this->laravel->make(ChannelManager::class),
$browser
);
}); });
$this->loop->addPeriodicTimer(config('websockets.statistics.interval_in_seconds'), function () { $this->loop->addPeriodicTimer(config('websockets.statistics.interval_in_seconds'), function () {
@ -122,6 +133,28 @@ class StartWebSocketServer extends Command
return $this; return $this;
} }
/**
* Configure the replicators.
*
* @return void
*/
public function configurePubSub()
{
if (config('websockets.replication.driver', 'local') === 'local') {
$this->laravel->singleton(ReplicationInterface::class, function () {
return new LocalClient;
});
}
if (config('websockets.replication.driver', 'local') === 'redis') {
$this->laravel->singleton(ReplicationInterface::class, function () {
return (new RedisClient)->boot($this->loop);
});
}
return $this;
}
protected function registerEchoRoutes() protected function registerEchoRoutes()
{ {
WebSocketsRouter::echo(); WebSocketsRouter::echo();
@ -142,20 +175,25 @@ class StartWebSocketServer extends Command
$routes = WebSocketsRouter::getRoutes(); $routes = WebSocketsRouter::getRoutes();
/* 🛰 Start the server 🛰 */ $server = (new WebSocketServerFactory())
(new WebSocketServerFactory())
->setLoop($this->loop) ->setLoop($this->loop)
->useRoutes($routes) ->useRoutes($routes)
->setHost($this->option('host')) ->setHost($this->option('host'))
->setPort($this->option('port')) ->setPort($this->option('port'))
->setConsoleOutput($this->output) ->setConsoleOutput($this->output)
->createServer() ->createServer();
->run();
if (! $this->option('test')) {
/* 🛰 Start the server 🛰 */
$server->run();
}
} }
protected function configurePubSubReplication() protected function configurePubSubReplication()
{ {
$this->laravel->get(ReplicationInterface::class)->boot($this->loop); $this->laravel
->get(ReplicationInterface::class)
->boot($this->loop);
return $this; return $this;
} }

View File

@ -9,68 +9,116 @@ use stdClass;
class DashboardLogger class DashboardLogger
{ {
const LOG_CHANNEL_PREFIX = 'private-websockets-dashboard-'; const LOG_CHANNEL_PREFIX = 'private-websockets-dashboard-';
const TYPE_DISCONNECTION = 'disconnection'; const TYPE_DISCONNECTION = 'disconnection';
const TYPE_CONNECTION = 'connection'; const TYPE_CONNECTION = 'connection';
const TYPE_VACATED = 'vacated'; const TYPE_VACATED = 'vacated';
const TYPE_OCCUPIED = 'occupied'; const TYPE_OCCUPIED = 'occupied';
const TYPE_SUBSCRIBED = 'subscribed'; const TYPE_SUBSCRIBED = 'subscribed';
const TYPE_CLIENT_MESSAGE = 'client-message'; const TYPE_CLIENT_MESSAGE = 'client-message';
const TYPE_API_MESSAGE = 'api-message'; const TYPE_API_MESSAGE = 'api-message';
const TYPE_REPLICATOR_SUBSCRIBED = 'replicator-subscribed';
const TYPE_REPLICATOR_UNSUBSCRIBED = 'replicator-unsubscribed';
public static function connection(ConnectionInterface $connection) public static function connection(ConnectionInterface $connection)
{ {
/** @var \GuzzleHttp\Psr7\Request $request */ /** @var \GuzzleHttp\Psr7\Request $request */
$request = $connection->httpRequest; $request = $connection->httpRequest;
static::log($connection->app->id, static::TYPE_CONNECTION, [ static::log($connection->app->id, static::TYPE_CONNECTION, [
'details' => "Origin: {$request->getUri()->getScheme()}://{$request->getUri()->getHost()}", 'details' => [
'origin' => "{$request->getUri()->getScheme()}://{$request->getUri()->getHost()}",
'socketId' => $connection->socketId, 'socketId' => $connection->socketId,
],
]); ]);
} }
public static function occupied(ConnectionInterface $connection, string $channelName) public static function occupied(ConnectionInterface $connection, string $channelName)
{ {
static::log($connection->app->id, static::TYPE_OCCUPIED, [ static::log($connection->app->id, static::TYPE_OCCUPIED, [
'details' => "Channel: {$channelName}", 'details' => [
'channel' => $channelName,
],
]); ]);
} }
public static function subscribed(ConnectionInterface $connection, string $channelName) public static function subscribed(ConnectionInterface $connection, string $channelName)
{ {
static::log($connection->app->id, static::TYPE_SUBSCRIBED, [ static::log($connection->app->id, static::TYPE_SUBSCRIBED, [
'details' => [
'socketId' => $connection->socketId, 'socketId' => $connection->socketId,
'details' => "Channel: {$channelName}", 'channel' => $channelName,
],
]); ]);
} }
public static function clientMessage(ConnectionInterface $connection, stdClass $payload) public static function clientMessage(ConnectionInterface $connection, stdClass $payload)
{ {
static::log($connection->app->id, static::TYPE_CLIENT_MESSAGE, [ static::log($connection->app->id, static::TYPE_CLIENT_MESSAGE, [
'details' => "Channel: {$payload->channel}, Event: {$payload->event}", 'details' => [
'socketId' => $connection->socketId, 'socketId' => $connection->socketId,
'data' => json_encode($payload), 'channel' => $payload->channel,
'event' => $payload->event,
'data' => $payload,
],
]); ]);
} }
public static function disconnection(ConnectionInterface $connection) public static function disconnection(ConnectionInterface $connection)
{ {
static::log($connection->app->id, static::TYPE_DISCONNECTION, [ static::log($connection->app->id, static::TYPE_DISCONNECTION, [
'details' => [
'socketId' => $connection->socketId, 'socketId' => $connection->socketId,
],
]); ]);
} }
public static function vacated(ConnectionInterface $connection, string $channelName) public static function vacated(ConnectionInterface $connection, string $channelName)
{ {
static::log($connection->app->id, static::TYPE_VACATED, [ static::log($connection->app->id, static::TYPE_VACATED, [
'details' => "Channel: {$channelName}", 'details' => [
'socketId' => $connection->socketId,
'channel' => $channelName,
],
]); ]);
} }
public static function apiMessage($appId, string $channel, string $event, string $payload) public static function apiMessage($appId, string $channel, string $event, string $payload)
{ {
static::log($appId, static::TYPE_API_MESSAGE, [ static::log($appId, static::TYPE_API_MESSAGE, [
'details' => "Channel: {$channel}, Event: {$event}", 'details' => [
'data' => $payload, 'channel' => $connection,
'event' => $event,
'payload' => $payload,
],
]);
}
public static function replicatorSubscribed(string $appId, string $channel, string $serverId)
{
static::log($appId, static::TYPE_REPLICATOR_SUBSCRIBED, [
'details' => [
'serverId' => $serverId,
'channel' => $channel,
],
]);
}
public static function replicatorUnsubscribed(string $appId, string $channel, string $serverId)
{
static::log($appId, static::TYPE_REPLICATOR_UNSUBSCRIBED, [
'details' => [
'serverId' => $serverId,
'channel' => $channel,
],
]); ]);
} }

View File

@ -8,18 +8,19 @@ use BeyondCode\LaravelWebSockets\WebSockets\Channels\PresenceChannel;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use stdClass;
use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\HttpException;
class FetchChannelsController extends Controller class FetchChannelsController extends Controller
{ {
/** @var ReplicationInterface */ /** @var ReplicationInterface */
protected $replication; protected $replicator;
public function __construct(ChannelManager $channelManager, ReplicationInterface $replication) public function __construct(ChannelManager $channelManager, ReplicationInterface $replicator)
{ {
parent::__construct($channelManager); parent::__construct($channelManager);
$this->replication = $replication; $this->replicator = $replicator;
} }
public function __invoke(Request $request) public function __invoke(Request $request)
@ -51,18 +52,21 @@ class FetchChannelsController extends Controller
// We ask the replication backend to get us the member count per channel. // We ask the replication backend to get us the member count per channel.
// We get $counts back as a key-value array of channel names and their member count. // We get $counts back as a key-value array of channel names and their member count.
return $this->replication return $this->replicator
->channelMemberCounts($request->appId, $channelNames) ->channelMemberCounts($request->appId, $channelNames)
->then(function (array $counts) use ($channels, $attributes) { ->then(function (array $counts) use ($channels, $attributes) {
return [ $channels = $channels->map(function (PresenceChannel $channel) use ($counts, $attributes) {
'channels' => $channels->map(function (PresenceChannel $channel) use ($counts, $attributes) { $info = new stdClass;
$info = new \stdClass;
if (in_array('user_count', $attributes)) { if (in_array('user_count', $attributes)) {
$info->user_count = $counts[$channel->getChannelName()]; $info->user_count = $counts[$channel->getChannelName()];
} }
return $info; return $info;
})->toArray() ?: new \stdClass, })->toArray();
return [
'channels' => $channels ?: new stdClass,
]; ];
}); });
} }

View File

@ -21,9 +21,10 @@ class LocalClient implements ReplicationInterface
* Boot the pub/sub provider (open connections, initial subscriptions, etc). * Boot the pub/sub provider (open connections, initial subscriptions, etc).
* *
* @param LoopInterface $loop * @param LoopInterface $loop
* @param string|null $factoryClass
* @return self * @return self
*/ */
public function boot(LoopInterface $loop): ReplicationInterface public function boot(LoopInterface $loop, $factoryClass = null): ReplicationInterface
{ {
return $this; return $this;
} }
@ -38,7 +39,6 @@ class LocalClient implements ReplicationInterface
*/ */
public function publish(string $appId, string $channel, stdClass $payload): bool public function publish(string $appId, string $channel, stdClass $payload): bool
{ {
// Nothing to do, nobody to publish to
return true; return true;
} }
@ -74,6 +74,7 @@ class LocalClient implements ReplicationInterface
* @param string $channel * @param string $channel
* @param string $socketId * @param string $socketId
* @param string $data * @param string $data
* @return void
*/ */
public function joinChannel(string $appId, string $channel, string $socketId, string $data) public function joinChannel(string $appId, string $channel, string $socketId, string $data)
{ {
@ -87,10 +88,12 @@ class LocalClient implements ReplicationInterface
* @param string $appId * @param string $appId
* @param string $channel * @param string $channel
* @param string $socketId * @param string $socketId
* @return void
*/ */
public function leaveChannel(string $appId, string $channel, string $socketId) public function leaveChannel(string $appId, string $channel, string $socketId)
{ {
unset($this->channelData["$appId:$channel"][$socketId]); unset($this->channelData["$appId:$channel"][$socketId]);
if (empty($this->channelData["$appId:$channel"])) { if (empty($this->channelData["$appId:$channel"])) {
unset($this->channelData["$appId:$channel"]); unset($this->channelData["$appId:$channel"]);
} }
@ -107,7 +110,6 @@ class LocalClient implements ReplicationInterface
{ {
$members = $this->channelData["$appId:$channel"] ?? []; $members = $this->channelData["$appId:$channel"] ?? [];
// The data is expected as objects, so we need to JSON decode
$members = array_map(function ($user) { $members = array_map(function ($user) {
return json_decode($user); return json_decode($user);
}, $members); }, $members);

View File

@ -2,6 +2,7 @@
namespace BeyondCode\LaravelWebSockets\PubSub\Drivers; namespace BeyondCode\LaravelWebSockets\PubSub\Drivers;
use BeyondCode\LaravelWebSockets\Dashboard\DashboardLogger;
use BeyondCode\LaravelWebSockets\PubSub\ReplicationInterface; use BeyondCode\LaravelWebSockets\PubSub\ReplicationInterface;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager; use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager;
use Clue\React\Redis\Client; use Clue\React\Redis\Client;
@ -14,21 +15,29 @@ use stdClass;
class RedisClient implements ReplicationInterface class RedisClient implements ReplicationInterface
{ {
/** /**
* The running loop.
*
* @var LoopInterface * @var LoopInterface
*/ */
protected $loop; protected $loop;
/** /**
* The unique server identifier.
*
* @var string * @var string
*/ */
protected $serverId; protected $serverId;
/** /**
* The pub client.
*
* @var Client * @var Client
*/ */
protected $publishClient; protected $publishClient;
/** /**
* The sub client.
*
* @var Client * @var Client
*/ */
protected $subscribeClient; protected $subscribeClient;
@ -44,7 +53,9 @@ class RedisClient implements ReplicationInterface
protected $subscribedChannels = []; protected $subscribedChannels = [];
/** /**
* RedisClient constructor. * Create a new Redis client.
*
* @return void
*/ */
public function __construct() public function __construct()
{ {
@ -55,18 +66,22 @@ class RedisClient implements ReplicationInterface
* Boot the RedisClient, initializing the connections. * Boot the RedisClient, initializing the connections.
* *
* @param LoopInterface $loop * @param LoopInterface $loop
* @param string|null $factoryClass
* @return ReplicationInterface * @return ReplicationInterface
*/ */
public function boot(LoopInterface $loop): ReplicationInterface public function boot(LoopInterface $loop, $factoryClass = null): ReplicationInterface
{ {
$factoryClass = $factoryClass ?: Factory::class;
$this->loop = $loop; $this->loop = $loop;
$connectionUri = $this->getConnectionUri(); $connectionUri = $this->getConnectionUri();
$factory = new Factory($this->loop); $factory = new $factoryClass($this->loop);
$this->publishClient = $factory->createLazyClient($connectionUri); $this->publishClient = $factory->createLazyClient($connectionUri);
$this->subscribeClient = $factory->createLazyClient($connectionUri); $this->subscribeClient = $factory->createLazyClient($connectionUri);
// The subscribed client gets a message, it triggers the onMessage().
$this->subscribeClient->on('message', function ($channel, $payload) { $this->subscribeClient->on('message', function ($channel, $payload) {
$this->onMessage($channel, $payload); $this->onMessage($channel, $payload);
}); });
@ -79,12 +94,13 @@ class RedisClient implements ReplicationInterface
* *
* @param string $redisChannel * @param string $redisChannel
* @param string $payload * @param string $payload
* @return void
*/ */
protected function onMessage(string $redisChannel, string $payload) protected function onMessage(string $redisChannel, string $payload)
{ {
$payload = json_decode($payload); $payload = json_decode($payload);
// Ignore messages sent by ourselves // Ignore messages sent by ourselves.
if (isset($payload->serverId) && $this->serverId === $payload->serverId) { if (isset($payload->serverId) && $this->serverId === $payload->serverId) {
return; return;
} }
@ -95,12 +111,11 @@ class RedisClient implements ReplicationInterface
// We need to put the channel name in the payload. // We need to put the channel name in the payload.
// We strip the app ID from the channel name, websocket clients // We strip the app ID from the channel name, websocket clients
// expect the channel name to not include the app ID. // expect the channel name to not include the app ID.
$payload->channel = Str::after($redisChannel, "$appId:"); $payload->channel = Str::after($redisChannel, "{$appId}:");
/* @var ChannelManager $channelManager */
$channelManager = app(ChannelManager::class); $channelManager = app(ChannelManager::class);
// Load the Channel instance, if any // Load the Channel instance to sync.
$channel = $channelManager->find($appId, $payload->channel); $channel = $channelManager->find($appId, $payload->channel);
// If no channel is found, none of our connections want to // If no channel is found, none of our connections want to
@ -111,12 +126,12 @@ class RedisClient implements ReplicationInterface
$socket = $payload->socket ?? null; $socket = $payload->socket ?? null;
// Remove fields intended for internal use from the payload // Remove fields intended for internal use from the payload.
unset($payload->socket); unset($payload->socket);
unset($payload->serverId); unset($payload->serverId);
unset($payload->appId); unset($payload->appId);
// Push the message out to connected websocket clients // Push the message out to connected websocket clients.
$channel->broadcastToEveryoneExcept($payload, $socket, $appId, false); $channel->broadcastToEveryoneExcept($payload, $socket, $appId, false);
} }
@ -138,6 +153,8 @@ class RedisClient implements ReplicationInterface
$this->subscribedChannels["$appId:$channel"]++; $this->subscribedChannels["$appId:$channel"]++;
} }
DashboardLogger::replicatorSubscribed($appId, $channel, $this->serverId);
return true; return true;
} }
@ -160,9 +177,12 @@ class RedisClient implements ReplicationInterface
// If we no longer have subscriptions to that channel, unsubscribe // If we no longer have subscriptions to that channel, unsubscribe
if ($this->subscribedChannels["$appId:$channel"] < 1) { if ($this->subscribedChannels["$appId:$channel"] < 1) {
$this->subscribeClient->__call('unsubscribe', ["$appId:$channel"]); $this->subscribeClient->__call('unsubscribe', ["$appId:$channel"]);
unset($this->subscribedChannels["$appId:$channel"]); unset($this->subscribedChannels["$appId:$channel"]);
} }
DashboardLogger::replicatorUnsubscribed($appId, $channel, $this->serverId);
return true; return true;
} }
@ -192,6 +212,7 @@ class RedisClient implements ReplicationInterface
* @param string $channel * @param string $channel
* @param string $socketId * @param string $socketId
* @param string $data * @param string $data
* @return void
*/ */
public function joinChannel(string $appId, string $channel, string $socketId, string $data) public function joinChannel(string $appId, string $channel, string $socketId, string $data)
{ {
@ -205,6 +226,7 @@ class RedisClient implements ReplicationInterface
* @param string $appId * @param string $appId
* @param string $channel * @param string $channel
* @param string $socketId * @param string $socketId
* @return void
*/ */
public function leaveChannel(string $appId, string $channel, string $socketId) public function leaveChannel(string $appId, string $channel, string $socketId)
{ {
@ -257,20 +279,54 @@ class RedisClient implements ReplicationInterface
*/ */
protected function getConnectionUri() protected function getConnectionUri()
{ {
$name = config('websockets.replication.connection') ?? 'default'; $name = config('websockets.replication.redis.connection') ?: 'default';
$config = config("database.redis.$name"); $config = config('database.redis')[$name];
$host = $config['host']; $host = $config['host'];
$port = $config['port'] ? (':'.$config['port']) : ':6379'; $port = $config['port'] ?: 6379;
$query = []; $query = [];
if ($config['password']) { if ($config['password']) {
$query['password'] = $config['password']; $query['password'] = $config['password'];
} }
if ($config['database']) { if ($config['database']) {
$query['database'] = $config['database']; $query['database'] = $config['database'];
} }
$query = http_build_query($query); $query = http_build_query($query);
return "redis://$host$port".($query ? '?'.$query : ''); return "redis://{$host}:{$port}".($query ? "?{$query}" : '');
}
/**
* Get the Subscribe client instance.
*
* @return Client
*/
public function getSubscribeClient()
{
return $this->subscribeClient;
}
/**
* Get the Publish client instance.
*
* @return Client
*/
public function getPublishClient()
{
return $this->publishClient;
}
/**
* Get the unique identifier for the server.
*
* @return string
*/
public function getServerId()
{
return $this->serverId;
} }
} }

View File

@ -12,9 +12,10 @@ interface ReplicationInterface
* Boot the pub/sub provider (open connections, initial subscriptions, etc). * Boot the pub/sub provider (open connections, initial subscriptions, etc).
* *
* @param LoopInterface $loop * @param LoopInterface $loop
* @param string|null $factoryClass
* @return self * @return self
*/ */
public function boot(LoopInterface $loop): self; public function boot(LoopInterface $loop, $factoryClass = null): self;
/** /**
* Publish a payload on a specific channel, for a specific app. * Publish a payload on a specific channel, for a specific app.
@ -52,6 +53,7 @@ interface ReplicationInterface
* @param string $channel * @param string $channel
* @param string $socketId * @param string $socketId
* @param string $data * @param string $data
* @return void
*/ */
public function joinChannel(string $appId, string $channel, string $socketId, string $data); public function joinChannel(string $appId, string $channel, string $socketId, string $data);
@ -62,6 +64,7 @@ interface ReplicationInterface
* @param string $appId * @param string $appId
* @param string $channel * @param string $channel
* @param string $socketId * @param string $socketId
* @return void
*/ */
public function leaveChannel(string $appId, string $channel, string $socketId); public function leaveChannel(string $appId, string $channel, string $socketId);

View File

@ -15,7 +15,7 @@ class Channel
protected $channelName; protected $channelName;
/** @var ReplicationInterface */ /** @var ReplicationInterface */
protected $replication; protected $replicator;
/** @var \Ratchet\ConnectionInterface[] */ /** @var \Ratchet\ConnectionInterface[] */
protected $subscribedConnections = []; protected $subscribedConnections = [];
@ -23,7 +23,7 @@ class Channel
public function __construct(string $channelName) public function __construct(string $channelName)
{ {
$this->channelName = $channelName; $this->channelName = $channelName;
$this->replication = app(ReplicationInterface::class); $this->replicator = app(ReplicationInterface::class);
} }
public function getChannelName(): string public function getChannelName(): string
@ -67,21 +67,19 @@ class Channel
{ {
$this->saveConnection($connection); $this->saveConnection($connection);
// Subscribe to broadcasted messages from the pub/sub backend
$this->replication->subscribe($connection->app->id, $this->channelName);
$connection->send(json_encode([ $connection->send(json_encode([
'event' => 'pusher_internal:subscription_succeeded', 'event' => 'pusher_internal:subscription_succeeded',
'channel' => $this->channelName, 'channel' => $this->channelName,
])); ]));
$this->replicator->subscribe($connection->app->id, $this->channelName);
} }
public function unsubscribe(ConnectionInterface $connection) public function unsubscribe(ConnectionInterface $connection)
{ {
unset($this->subscribedConnections[$connection->socketId]); unset($this->subscribedConnections[$connection->socketId]);
// Unsubscribe from the pub/sub backend $this->replicator->unsubscribe($connection->app->id, $this->channelName);
$this->replication->unsubscribe($connection->app->id, $this->channelName);
if (! $this->hasConnections()) { if (! $this->hasConnections()) {
DashboardLogger::vacated($connection, $this->channelName); DashboardLogger::vacated($connection, $this->channelName);
@ -120,7 +118,7 @@ class Channel
// in this case. If this came from TriggerEventController, then we still want // in this case. If this came from TriggerEventController, then we still want
// to publish to get the message out to other server instances. // to publish to get the message out to other server instances.
if ($publish) { if ($publish) {
$this->replication->publish($appId, $this->channelName, $payload); $this->replicator->publish($appId, $this->channelName, $payload);
} }
// Performance optimization, if we don't have a socket ID, // Performance optimization, if we don't have a socket ID,

View File

@ -22,22 +22,24 @@ class PresenceChannel extends Channel
protected $users = []; protected $users = [];
/** /**
* Get the members in the presence channel.
*
* @param string $appId * @param string $appId
* @return PromiseInterface * @return PromiseInterface
*/ */
public function getUsers(string $appId) public function getUsers(string $appId)
{ {
// Get the members list from the replication backend return $this->replicator->channelMembers($appId, $this->channelName);
return $this->replication
->channelMembers($appId, $this->channelName);
} }
/** /**
* @link https://pusher.com/docs/pusher_protocol#presence-channel-events * Subscribe the connection to the channel.
* *
* @param ConnectionInterface $connection * @param ConnectionInterface $connection
* @param stdClass $payload * @param stdClass $payload
* @return void
* @throws InvalidSignature * @throws InvalidSignature
* @see https://pusher.com/docs/pusher_protocol#presence-channel-events
*/ */
public function subscribe(ConnectionInterface $connection, stdClass $payload) public function subscribe(ConnectionInterface $connection, stdClass $payload)
{ {
@ -49,8 +51,7 @@ class PresenceChannel extends Channel
$this->users[$connection->socketId] = $channelData; $this->users[$connection->socketId] = $channelData;
// Add the connection as a member of the channel // Add the connection as a member of the channel
$this->replication $this->replicator->joinChannel(
->joinChannel(
$connection->app->id, $connection->app->id,
$this->channelName, $this->channelName,
$connection->socketId, $connection->socketId,
@ -59,10 +60,9 @@ class PresenceChannel extends Channel
// We need to pull the channel data from the replication backend, // We need to pull the channel data from the replication backend,
// otherwise we won't be sending the full details of the channel // otherwise we won't be sending the full details of the channel
$this->replication $this->replicator
->channelMembers($connection->app->id, $this->channelName) ->channelMembers($connection->app->id, $this->channelName)
->then(function ($users) use ($connection) { ->then(function ($users) use ($connection) {
// Send the success event
$connection->send(json_encode([ $connection->send(json_encode([
'event' => 'pusher_internal:subscription_succeeded', 'event' => 'pusher_internal:subscription_succeeded',
'channel' => $this->channelName, 'channel' => $this->channelName,
@ -77,6 +77,12 @@ class PresenceChannel extends Channel
]); ]);
} }
/**
* Unsubscribe the connection from the Presence channel.
*
* @param ConnectionInterface $connection
* @return void
*/
public function unsubscribe(ConnectionInterface $connection) public function unsubscribe(ConnectionInterface $connection)
{ {
parent::unsubscribe($connection); parent::unsubscribe($connection);
@ -86,7 +92,7 @@ class PresenceChannel extends Channel
} }
// Remove the connection as a member of the channel // Remove the connection as a member of the channel
$this->replication $this->replicator
->leaveChannel( ->leaveChannel(
$connection->app->id, $connection->app->id,
$this->channelName, $this->channelName,
@ -105,12 +111,14 @@ class PresenceChannel extends Channel
} }
/** /**
* Get the Presence Channel to array.
*
* @param string|null $appId * @param string|null $appId
* @return PromiseInterface * @return PromiseInterface
*/ */
public function toArray(string $appId = null) public function toArray(string $appId = null)
{ {
return $this->replication return $this->replicator
->channelMembers($appId, $this->channelName) ->channelMembers($appId, $this->channelName)
->then(function ($users) { ->then(function ($users) {
return array_merge(parent::toArray(), [ return array_merge(parent::toArray(), [
@ -119,6 +127,12 @@ class PresenceChannel extends Channel
}); });
} }
/**
* Get the Presence channel data.
*
* @param array $users
* @return array
*/
protected function getChannelData(array $users): array protected function getChannelData(array $users): array
{ {
return [ return [
@ -130,6 +144,12 @@ class PresenceChannel extends Channel
]; ];
} }
/**
* Get the Presence Channel's users.
*
* @param array $users
* @return array
*/
protected function getUserIds(array $users): array protected function getUserIds(array $users): array
{ {
$userIds = array_map(function ($channelData) { $userIds = array_map(function ($channelData) {

View File

@ -9,9 +9,6 @@ use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\SendMessage;
use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\ShowDashboard; use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\ShowDashboard;
use BeyondCode\LaravelWebSockets\Dashboard\Http\Middleware\Authorize as AuthorizeDashboard; use BeyondCode\LaravelWebSockets\Dashboard\Http\Middleware\Authorize as AuthorizeDashboard;
use BeyondCode\LaravelWebSockets\PubSub\Broadcasters\RedisPusherBroadcaster; use BeyondCode\LaravelWebSockets\PubSub\Broadcasters\RedisPusherBroadcaster;
use BeyondCode\LaravelWebSockets\PubSub\Drivers\LocalClient;
use BeyondCode\LaravelWebSockets\PubSub\Drivers\RedisClient;
use BeyondCode\LaravelWebSockets\PubSub\ReplicationInterface;
use BeyondCode\LaravelWebSockets\Server\Router; use BeyondCode\LaravelWebSockets\Server\Router;
use BeyondCode\LaravelWebSockets\Statistics\Http\Controllers\WebSocketStatisticsEntriesController; use BeyondCode\LaravelWebSockets\Statistics\Http\Controllers\WebSocketStatisticsEntriesController;
use BeyondCode\LaravelWebSockets\Statistics\Http\Middleware\Authorize as AuthorizeStatistics; use BeyondCode\LaravelWebSockets\Statistics\Http\Middleware\Authorize as AuthorizeStatistics;
@ -53,19 +50,7 @@ class WebSocketsServiceProvider extends ServiceProvider
protected function configurePubSub() protected function configurePubSub()
{ {
if (config('websockets.replication.enabled') !== true || config('websockets.replication.driver') !== 'redis') { $this->app->make(BroadcastManager::class)->extend('websockets', function ($app, array $config) {
$this->app->singleton(ReplicationInterface::class, function () {
return new LocalClient();
});
return;
}
$this->app->singleton(ReplicationInterface::class, function () {
return (new RedisClient())->boot($this->loop);
});
$this->app->get(BroadcastManager::class)->extend('redis-pusher', function ($app, array $config) {
$pusher = new Pusher( $pusher = new Pusher(
$config['key'], $config['secret'], $config['key'], $config['secret'],
$config['app_id'], $config['options'] ?? [] $config['app_id'], $config['options'] ?? []

View File

@ -2,16 +2,24 @@
namespace BeyondCode\LaravelWebSockets\Tests\Channels; namespace BeyondCode\LaravelWebSockets\Tests\Channels;
use BeyondCode\LaravelWebSockets\Tests\TestsReplication; use BeyondCode\LaravelWebSockets\Tests\TestCase;
class ChannelReplicationTest extends ChannelTest class ChannelReplicationTest extends TestCase
{ {
use TestsReplication; /**
* {@inheritdoc}
*/
public function setUp(): void public function setUp(): void
{ {
parent::setUp(); parent::setUp();
$this->setupReplication(); $this->runOnlyOnRedisReplication();
}
public function test_not_implemented()
{
$this->markTestIncomplete(
'Not yet implemented tests.'
);
} }
} }

View File

@ -2,16 +2,68 @@
namespace BeyondCode\LaravelWebSockets\Tests\Channels; namespace BeyondCode\LaravelWebSockets\Tests\Channels;
use BeyondCode\LaravelWebSockets\Tests\TestsReplication; use BeyondCode\LaravelWebSockets\Tests\Mocks\Message;
use BeyondCode\LaravelWebSockets\Tests\TestCase;
class PresenceChannelReplicationTest extends PresenceChannelTest class PresenceChannelReplicationTest extends TestCase
{ {
use TestsReplication; /**
* {@inheritdoc}
*/
public function setUp(): void public function setUp(): void
{ {
parent::setUp(); parent::setUp();
$this->setupReplication(); $this->runOnlyOnRedisReplication();
}
/** @test */
public function clients_with_valid_auth_signatures_can_join_presence_channels()
{
$connection = $this->getWebSocketConnection();
$this->pusherServer->onOpen($connection);
$channelData = [
'user_id' => 1,
'user_info' => [
'name' => 'Marcel',
],
];
$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()
->assertCalledWithArgs('hset', [
'1234:presence-channel',
$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(),
]),
]) */
} }
} }

View File

@ -31,6 +31,8 @@ class PresenceChannelTest extends TestCase
/** @test */ /** @test */
public function clients_with_valid_auth_signatures_can_join_presence_channels() public function clients_with_valid_auth_signatures_can_join_presence_channels()
{ {
$this->skipOnRedisReplication();
$connection = $this->getWebSocketConnection(); $connection = $this->getWebSocketConnection();
$this->pusherServer->onOpen($connection); $this->pusherServer->onOpen($connection);
@ -63,6 +65,8 @@ class PresenceChannelTest extends TestCase
/** @test */ /** @test */
public function clients_with_valid_auth_signatures_can_leave_presence_channels() public function clients_with_valid_auth_signatures_can_leave_presence_channels()
{ {
$this->skipOnRedisReplication();
$connection = $this->getWebSocketConnection(); $connection = $this->getWebSocketConnection();
$this->pusherServer->onOpen($connection); $this->pusherServer->onOpen($connection);
@ -102,6 +106,8 @@ class PresenceChannelTest extends TestCase
/** @test */ /** @test */
public function clients_with_no_user_info_can_join_presence_channels() public function clients_with_no_user_info_can_join_presence_channels()
{ {
$this->skipOnRedisReplication();
$connection = $this->getWebSocketConnection(); $connection = $this->getWebSocketConnection();
$this->pusherServer->onOpen($connection); $this->pusherServer->onOpen($connection);

View File

@ -0,0 +1,25 @@
<?php
namespace BeyondCode\LaravelWebSockets\Tests\Channels;
use BeyondCode\LaravelWebSockets\Tests\TestCase;
class PrivateChannelReplicationTest extends TestCase
{
/**
* {@inheritdoc}
*/
public function setUp(): void
{
parent::setUp();
$this->runOnlyOnRedisReplication();
}
public function test_not_implemented()
{
$this->markTestIncomplete(
'Not yet implemented tests.'
);
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace BeyondCode\LaravelWebSockets\Tests\Commands;
use BeyondCode\LaravelWebSockets\Tests\TestCase;
class StartWebSocketServerTest extends TestCase
{
/** @test */
public function does_not_fail_if_building_up()
{
$this->artisan('websockets:serve', ['--test' => true]);
$this->assertTrue(true);
}
}

View File

@ -2,16 +2,151 @@
namespace BeyondCode\LaravelWebSockets\Tests\HttpApi; namespace BeyondCode\LaravelWebSockets\Tests\HttpApi;
use BeyondCode\LaravelWebSockets\Tests\TestsReplication; use BeyondCode\LaravelWebSockets\HttpApi\Controllers\FetchChannelController;
use BeyondCode\LaravelWebSockets\Tests\Mocks\Connection;
use BeyondCode\LaravelWebSockets\Tests\TestCase;
use GuzzleHttp\Psr7\Request;
use Illuminate\Http\JsonResponse;
use Pusher\Pusher;
use Symfony\Component\HttpKernel\Exception\HttpException;
class FetchChannelReplicationTest extends FetchChannelTest class FetchChannelReplicationTest extends TestCase
{ {
use TestsReplication; /**
* {@inheritdoc}
*/
public function setUp(): void public function setUp(): void
{ {
parent::setUp(); parent::setUp();
$this->setupReplication(); $this->runOnlyOnRedisReplication();
}
/** @test */
public function replication_invalid_signatures_can_not_access_the_api()
{
$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(FetchChannelController::class);
$controller->onOpen($connection, $request);
}
/** @test */
public function replication_it_returns_the_channel_information()
{
$this->getConnectedWebSocketConnection(['my-channel']);
$this->getConnectedWebSocketConnection(['my-channel']);
$connection = new Connection();
$requestPath = '/apps/1234/channel/my-channel';
$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(FetchChannelController::class);
$controller->onOpen($connection, $request);
/** @var JsonResponse $response */
$response = array_pop($connection->sentRawData);
$this->assertSame([
'occupied' => true,
'subscription_count' => 2,
], json_decode($response->getContent(), true));
}
/** @test */
public function replication_it_returns_presence_channel_information()
{
$this->skipOnRedisReplication();
$this->joinPresenceChannel('presence-channel');
$this->joinPresenceChannel('presence-channel');
$connection = new Connection();
$requestPath = '/apps/1234/channel/my-channel';
$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(FetchChannelController::class);
$controller->onOpen($connection, $request);
/** @var JsonResponse $response */
$response = array_pop($connection->sentRawData);
$this->getSubscribeClient()->assertNothingCalled();
$this->getPublishClient()
->assertCalled('hset')
->assertCalled('hgetall')
->assertCalled('publish');
$this->assertSame([
'occupied' => true,
'subscription_count' => 2,
'user_count' => 2,
], json_decode($response->getContent(), true));
}
/** @test */
public function replication_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';
$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(FetchChannelController::class);
$controller->onOpen($connection, $request);
/** @var JsonResponse $response */
$response = array_pop($connection->sentRawData);
$this->assertSame([
'occupied' => true,
'subscription_count' => 2,
], json_decode($response->getContent(), true));
} }
} }

View File

@ -69,6 +69,8 @@ class FetchChannelTest extends TestCase
/** @test */ /** @test */
public function it_returns_presence_channel_information() public function it_returns_presence_channel_information()
{ {
$this->runOnlyOnLocalReplication();
$this->joinPresenceChannel('presence-channel'); $this->joinPresenceChannel('presence-channel');
$this->joinPresenceChannel('presence-channel'); $this->joinPresenceChannel('presence-channel');

View File

@ -2,16 +2,24 @@
namespace BeyondCode\LaravelWebSockets\Tests\HttpApi; namespace BeyondCode\LaravelWebSockets\Tests\HttpApi;
use BeyondCode\LaravelWebSockets\Tests\TestsReplication; use BeyondCode\LaravelWebSockets\Tests\TestCase;
class FetchChannelsReplicationTest extends FetchChannelsTest class FetchChannelsReplicationTest extends TestCase
{ {
use TestsReplication; /**
* {@inheritdoc}
*/
public function setUp(): void public function setUp(): void
{ {
parent::setUp(); parent::setUp();
$this->setupReplication(); $this->runOnlyOnRedisReplication();
}
public function test_not_implemented()
{
$this->markTestIncomplete(
'Not yet implemented tests.'
);
} }
} }

View File

@ -37,6 +37,8 @@ class FetchChannelsTest extends TestCase
/** @test */ /** @test */
public function it_returns_the_channel_information() public function it_returns_the_channel_information()
{ {
$this->skipOnRedisReplication();
$this->joinPresenceChannel('presence-channel'); $this->joinPresenceChannel('presence-channel');
$connection = new Connection(); $connection = new Connection();
@ -67,6 +69,8 @@ class FetchChannelsTest extends TestCase
/** @test */ /** @test */
public function it_returns_the_channel_information_for_prefix() public function it_returns_the_channel_information_for_prefix()
{ {
$this->skipOnRedisReplication();
$this->joinPresenceChannel('presence-global.1'); $this->joinPresenceChannel('presence-global.1');
$this->joinPresenceChannel('presence-global.1'); $this->joinPresenceChannel('presence-global.1');
$this->joinPresenceChannel('presence-global.2'); $this->joinPresenceChannel('presence-global.2');
@ -103,6 +107,8 @@ class FetchChannelsTest extends TestCase
/** @test */ /** @test */
public function it_returns_the_channel_information_for_prefix_with_user_count() public function it_returns_the_channel_information_for_prefix_with_user_count()
{ {
$this->skipOnRedisReplication();
$this->joinPresenceChannel('presence-global.1'); $this->joinPresenceChannel('presence-global.1');
$this->joinPresenceChannel('presence-global.1'); $this->joinPresenceChannel('presence-global.1');
$this->joinPresenceChannel('presence-global.2'); $this->joinPresenceChannel('presence-global.2');
@ -171,6 +177,8 @@ class FetchChannelsTest extends TestCase
/** @test */ /** @test */
public function it_returns_empty_object_for_no_channels_found() public function it_returns_empty_object_for_no_channels_found()
{ {
$this->skipOnRedisReplication();
$connection = new Connection(); $connection = new Connection();
$requestPath = '/apps/1234/channels'; $requestPath = '/apps/1234/channels';

View File

@ -2,16 +2,24 @@
namespace BeyondCode\LaravelWebSockets\Tests\HttpApi; namespace BeyondCode\LaravelWebSockets\Tests\HttpApi;
use BeyondCode\LaravelWebSockets\Tests\TestsReplication; use BeyondCode\LaravelWebSockets\Tests\TestCase;
class FetchUsersReplicationTest extends FetchUsersTest class FetchUsersReplicationTest extends TestCase
{ {
use TestsReplication; /**
* {@inheritdoc}
*/
public function setUp(): void public function setUp(): void
{ {
parent::setUp(); parent::setUp();
$this->setupReplication(); $this->runOnlyOnRedisReplication();
}
public function test_not_implemented()
{
$this->markTestIncomplete(
'Not yet implemented tests.'
);
} }
} }

View File

@ -87,6 +87,8 @@ class FetchUsersTest extends TestCase
/** @test */ /** @test */
public function it_returns_connected_user_information() public function it_returns_connected_user_information()
{ {
$this->skipOnRedisReplication();
$this->joinPresenceChannel('presence-channel'); $this->joinPresenceChannel('presence-channel');
$connection = new Connection(); $connection = new Connection();

View File

@ -0,0 +1,95 @@
<?php
namespace BeyondCode\LaravelWebSockets\Tests\Mocks;
use Clue\React\Redis\LazyClient as BaseLazyClient;
use PHPUnit\Framework\Assert as PHPUnit;
class LazyClient extends BaseLazyClient
{
/**
* A list of called methods for the connector.
*
* @var array
*/
protected $calls = [];
/**
* {@inheritdoc}
*/
public function __call($name, $args)
{
$this->calls[] = [$name, $args];
return parent::__call($name, $args);
}
/**
* Check if the method got called.
*
* @param string $name
* @return $this
*/
public function assertCalled($name)
{
foreach ($this->getCalledFunctions() as $function) {
[$calledName, ] = $function;
if ($calledName === $name) {
PHPUnit::assertTrue(true);
return $this;
}
}
PHPUnit::assertFalse(true);
return $this;
}
/**
* Check if the method with args got called.
*
* @param string $name
* @param array $args
* @return $this
*/
public function assertCalledWithArgs($name, array $args)
{
foreach ($this->getCalledFunctions() as $function) {
[$calledName, $calledArgs] = $function;
if ($calledName === $name && $calledArgs === $args) {
PHPUnit::assertTrue(true);
return $this;
}
}
PHPUnit::assertFalse(true);
return $this;
}
/**
* Check if no function got called.
*
* @return $this
*/
public function assertNothingCalled()
{
PHPUnit::assertEquals([], $this->getCalledFunctions());
return $this;
}
/**
* Get the list of all calls.
*
* @return array
*/
public function getCalledFunctions()
{
return $this->calls;
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace BeyondCode\LaravelWebSockets\Tests\Mocks;
use Clue\React\Redis\Factory;
use Clue\Redis\Protocol\Factory as ProtocolFactory;
use React\EventLoop\LoopInterface;
use React\Socket\ConnectorInterface;
class RedisFactory extends Factory
{
/**
* The loop instance.
*
* @var LoopInterface
*/
private $loop;
/**
* {@inheritdoc}
*/
public function __construct(LoopInterface $loop, ConnectorInterface $connector = null, ProtocolFactory $protocol = null)
{
parent::__construct($loop, $connector, $protocol);
$this->loop = $loop;
}
/**
* Create Redis client connected to address of given redis instance.
*
* @param string $target
* @return Client
*/
public function createLazyClient($target)
{
return new LazyClient($target, $this, $this->loop);
}
}

View File

@ -3,16 +3,19 @@
namespace BeyondCode\LaravelWebSockets\Tests; namespace BeyondCode\LaravelWebSockets\Tests;
use BeyondCode\LaravelWebSockets\Facades\StatisticsLogger; use BeyondCode\LaravelWebSockets\Facades\StatisticsLogger;
use BeyondCode\LaravelWebSockets\PubSub\Drivers\LocalClient;
use BeyondCode\LaravelWebSockets\PubSub\Drivers\RedisClient;
use BeyondCode\LaravelWebSockets\PubSub\ReplicationInterface;
use BeyondCode\LaravelWebSockets\Tests\Mocks\Connection; use BeyondCode\LaravelWebSockets\Tests\Mocks\Connection;
use BeyondCode\LaravelWebSockets\Tests\Mocks\Message; use BeyondCode\LaravelWebSockets\Tests\Mocks\Message;
use BeyondCode\LaravelWebSockets\Tests\Statistics\Logger\FakeStatisticsLogger; use BeyondCode\LaravelWebSockets\Tests\Statistics\Logger\FakeStatisticsLogger;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager; use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager;
use BeyondCode\LaravelWebSockets\WebSockets\WebSocketHandler; use BeyondCode\LaravelWebSockets\WebSockets\WebSocketHandler;
use BeyondCode\LaravelWebSockets\WebSocketsServiceProvider;
use Clue\React\Buzz\Browser; use Clue\React\Buzz\Browser;
use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Request;
use Mockery; use Mockery;
use Ratchet\ConnectionInterface; use Ratchet\ConnectionInterface;
use React\EventLoop\Factory as LoopFactory;
abstract class TestCase extends \Orchestra\Testbench\TestCase abstract class TestCase extends \Orchestra\Testbench\TestCase
{ {
@ -22,6 +25,9 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
/** @var \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager */ /** @var \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager */
protected $channelManager; protected $channelManager;
/**
* {@inheritdoc}
*/
public function setUp(): void public function setUp(): void
{ {
parent::setUp(); parent::setUp();
@ -36,13 +42,23 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
)); ));
$this->loadMigrationsFrom(__DIR__.'/../database/migrations'); $this->loadMigrationsFrom(__DIR__.'/../database/migrations');
$this->configurePubSub();
} }
/**
* {@inheritdoc}
*/
protected function getPackageProviders($app) protected function getPackageProviders($app)
{ {
return [WebSocketsServiceProvider::class]; return [
\BeyondCode\LaravelWebSockets\WebSocketsServiceProvider::class,
];
} }
/**
* {@inheritdoc}
*/
protected function getEnvironmentSetUp($app) protected function getEnvironmentSetUp($app)
{ {
$app['config']->set('websockets.apps', [ $app['config']->set('websockets.apps', [
@ -57,6 +73,39 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
'enable_statistics' => true, 'enable_statistics' => true,
], ],
]); ]);
$app['config']->set('database.redis.default', [
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD', null),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
]);
$replicationDriver = getenv('REPLICATION_DRIVER') ?: 'local';
$app['config']->set(
'websockets.replication.driver', $replicationDriver
);
$app['config']->set(
'broadcasting.connections.websockets', [
'driver' => 'websockets',
'key' => 'TestKey',
'secret' => 'TestSecret',
'app_id' => '1234',
'options' => [
'cluster' => 'mt1',
'encrypted' => true,
'host' => '127.0.0.1',
'port' => 6001,
'scheme' => 'http',
],
]
);
if (in_array($replicationDriver, ['redis'])) {
$app['config']->set('broadcasting.default', 'websockets');
}
} }
protected function getWebSocketConnection(string $url = '/?appKey=TestKey'): Connection protected function getWebSocketConnection(string $url = '/?appKey=TestKey'): Connection
@ -124,8 +173,69 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
return $this->channelManager->findOrCreate($connection->app->id, $channelName); return $this->channelManager->findOrCreate($connection->app->id, $channelName);
} }
protected function configurePubSub()
{
// Replace the publish and subscribe clients with a Mocked
// factory lazy instance on boot.
if (config('websockets.replication.driver') === 'redis') {
$this->app->singleton(ReplicationInterface::class, function () {
return (new RedisClient)->boot(
LoopFactory::create(), Mocks\RedisFactory::class
);
});
}
if (config('websockets.replication.driver') === 'local') {
$this->app->singleton(ReplicationInterface::class, function () {
return new LocalClient;
});
}
}
protected function markTestAsPassed() protected function markTestAsPassed()
{ {
$this->assertTrue(true); $this->assertTrue(true);
} }
protected function runOnlyOnRedisReplication()
{
if (config('websockets.replication.driver') !== 'redis') {
$this->markTestSkipped('Skipped test because the replication driver is not set to Redis.');
}
}
protected function runOnlyOnLocalReplication()
{
if (config('websockets.replication.driver') !== 'local') {
$this->markTestSkipped('Skipped test because the replication driver is not set to Local.');
}
}
protected function skipOnRedisReplication()
{
if (config('websockets.replication.driver') === 'redis') {
$this->markTestSkipped('Skipped test because the replication driver is Redis.');
}
}
protected function skipOnLocalReplication()
{
if (config('websockets.replication.driver') === 'local') {
$this->markTestSkipped('Skipped test because the replication driver is Local.');
}
}
protected function getSubscribeClient()
{
return $this->app
->make(ReplicationInterface::class)
->getSubscribeClient();
}
protected function getPublishClient()
{
return $this->app
->make(ReplicationInterface::class)
->getPublishClient();
}
} }

View File

@ -1,22 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\Tests;
use BeyondCode\LaravelWebSockets\PubSub\Drivers\LocalClient;
use BeyondCode\LaravelWebSockets\PubSub\ReplicationInterface;
use Illuminate\Support\Facades\Config;
trait TestsReplication
{
public function setupReplication()
{
app()->singleton(ReplicationInterface::class, function () {
return new LocalClient();
});
Config::set([
'websockets.replication.enabled' => true,
'websockets.replication.driver' => 'redis',
]);
}
}