laravel-websockets/tests/HandlerLifecycleTest.php

473 lines
17 KiB
PHP
Raw Normal View History

<?php
namespace BlaxSoftware\LaravelWebSockets\Test;
use BlaxSoftware\LaravelWebSockets\Services\WebsocketService;
/**
* Comprehensive tests for the full WebSocket Handler lifecycle.
*
* These tests use the TestCase helper methods (newConnection, newActiveConnection,
* newPrivateConnection, newPresenceConnection) to exercise the Handler's
* connection management, channel subscriptions, and message routing.
*
* Covers:
* - Connection lifecycle (open, close, error)
* - Public/private/presence channel subscribe/unsubscribe
* - Ping/pong heartbeat
* - Protocol event responses
* - Connection isolation (errors don't leak across connections)
* - Cache tracking updates via Handler
*/
class HandlerLifecycleTest extends TestCase
{
// =========================================================================
// Connection establishment
// =========================================================================
public function test_new_connection_receives_connection_established_event()
{
$connection = $this->newConnection();
$this->wsHandler->onOpen($connection);
$connection->assertSentEvent('websocket.connection_established');
}
public function test_connection_established_contains_socket_id()
{
$connection = $this->newConnection();
$this->wsHandler->onOpen($connection);
$event = collect($connection->sentData)->firstWhere('event', 'websocket.connection_established');
$data = json_decode($event['data'], true);
$this->assertNotNull($data['socket_id']);
$this->assertStringContainsString('.', $data['socket_id']);
}
public function test_connection_established_contains_activity_timeout()
{
$connection = $this->newConnection();
$this->wsHandler->onOpen($connection);
$event = collect($connection->sentData)->firstWhere('event', 'websocket.connection_established');
$data = json_decode($event['data'], true);
$this->assertEquals(30, $data['activity_timeout']);
}
public function test_multiple_connections_get_unique_socket_ids()
{
$conn1 = $this->newConnection();
$conn2 = $this->newConnection();
$conn3 = $this->newConnection();
$this->wsHandler->onOpen($conn1);
$this->wsHandler->onOpen($conn2);
$this->wsHandler->onOpen($conn3);
$socket1 = json_decode((collect($conn1->sentData)->firstWhere('event', 'websocket.connection_established')['data'] ?? '{}'), true)['socket_id'] ?? null;
$socket2 = json_decode((collect($conn2->sentData)->firstWhere('event', 'websocket.connection_established')['data'] ?? '{}'), true)['socket_id'] ?? null;
$socket3 = json_decode((collect($conn3->sentData)->firstWhere('event', 'websocket.connection_established')['data'] ?? '{}'), true)['socket_id'] ?? null;
$this->assertNotNull($socket1);
$this->assertNotNull($socket2);
$this->assertNotNull($socket3);
$this->assertNotEquals($socket1, $socket2);
$this->assertNotEquals($socket2, $socket3);
$this->assertNotEquals($socket1, $socket3);
}
// =========================================================================
// Public channel subscribe/unsubscribe
// =========================================================================
public function test_subscribe_to_public_channel()
{
$connection = $this->newActiveConnection(['test-channel']);
$connection->assertSentEvent('websocket_internal.subscription_succeeded');
$channel = $this->channelManager->find('1234', 'test-channel');
$this->assertTrue($channel->hasConnection($connection));
}
public function test_subscribe_to_multiple_public_channels()
{
$connection = $this->newActiveConnection(['channel-a', 'channel-b', 'channel-c']);
$channelA = $this->channelManager->find('1234', 'channel-a');
$channelB = $this->channelManager->find('1234', 'channel-b');
$channelC = $this->channelManager->find('1234', 'channel-c');
$this->assertTrue($channelA->hasConnection($connection));
$this->assertTrue($channelB->hasConnection($connection));
$this->assertTrue($channelC->hasConnection($connection));
}
public function test_unsubscribe_from_public_channel()
{
$connection = $this->newActiveConnection(['test-channel']);
$channel = $this->channelManager->find('1234', 'test-channel');
$this->assertTrue($channel->hasConnection($connection));
$this->wsHandler->onMessage($connection, new Mocks\Message([
'event' => 'websocket.unsubscribe',
'data' => ['channel' => 'test-channel'],
]));
$this->assertFalse($channel->hasConnection($connection));
}
public function test_unsubscribe_does_not_affect_other_channels()
{
$connection = $this->newActiveConnection(['channel-a', 'channel-b']);
$this->wsHandler->onMessage($connection, new Mocks\Message([
'event' => 'websocket.unsubscribe',
'data' => ['channel' => 'channel-a'],
]));
$channelA = $this->channelManager->find('1234', 'channel-a');
$channelB = $this->channelManager->find('1234', 'channel-b');
$this->assertFalse($channelA->hasConnection($connection));
$this->assertTrue($channelB->hasConnection($connection));
}
// =========================================================================
// Private channel subscribe/unsubscribe
// =========================================================================
public function test_subscribe_to_private_channel_with_valid_signature()
{
$connection = $this->newPrivateConnection('private-test');
$connection->assertSentEvent('websocket_internal.subscription_succeeded');
$channel = $this->channelManager->find('1234', 'private-test');
$this->assertTrue($channel->hasConnection($connection));
}
public function test_subscribe_to_private_channel_with_invalid_signature_is_rejected()
{
$connection = $this->newConnection();
$this->wsHandler->onOpen($connection);
$message = new Mocks\Message([
'event' => 'websocket.subscribe',
'data' => [
'auth' => 'invalid-signature',
'channel' => 'private-test',
],
]);
$this->wsHandler->onMessage($connection, $message);
// Invalid signature is silently caught — no subscription_succeeded
$connection->assertNotSentEvent('websocket_internal.subscription_succeeded');
}
// =========================================================================
// Presence channel subscribe/unsubscribe
// =========================================================================
public function test_subscribe_to_presence_channel()
{
$connection = $this->newPresenceConnection('presence-chat', [
'user_id' => 1,
'user_info' => ['name' => 'Rick'],
]);
$connection->assertSentEvent('websocket_internal.subscription_succeeded');
$channel = $this->channelManager->find('1234', 'presence-chat');
$this->assertTrue($channel->hasConnection($connection));
}
public function test_presence_channel_member_added_on_second_connection()
{
$rick = $this->newPresenceConnection('presence-chat', [
'user_id' => 1,
'user_info' => ['name' => 'Rick'],
]);
$rick->resetEvents();
$morty = $this->newPresenceConnection('presence-chat', [
'user_id' => 2,
'user_info' => ['name' => 'Morty'],
]);
// Rick should receive a member_added event
$rick->assertSentEvent('websocket_internal.member_added');
}
public function test_presence_channel_shows_member_count()
{
$rick = $this->newPresenceConnection('presence-chat', [
'user_id' => 1,
'user_info' => ['name' => 'Rick'],
]);
$morty = $this->newPresenceConnection('presence-chat', [
'user_id' => 2,
'user_info' => ['name' => 'Morty'],
]);
$channel = $this->channelManager->find('1234', 'presence-chat');
$this->assertTrue($channel->hasConnection($rick));
$this->assertTrue($channel->hasConnection($morty));
}
// =========================================================================
// Ping/pong heartbeat
// =========================================================================
public function test_ping_receives_pong()
{
$connection = $this->newActiveConnection(['websocket']);
$connection->resetEvents();
$this->wsHandler->onMessage($connection, new Mocks\Message([
'event' => 'websocket.ping',
'data' => new \stdClass(),
]));
$connection->assertSentEvent('websocket.pong');
}
public function test_backward_compat_colon_ping_receives_pong()
{
$connection = $this->newActiveConnection(['websocket']);
$connection->resetEvents();
$this->wsHandler->onMessage($connection, new Mocks\Message([
'event' => 'pusher:ping',
'data' => new \stdClass(),
]));
$connection->assertSentEvent('websocket.pong');
}
public function test_ping_does_not_require_channel_subscription()
{
$connection = $this->newConnection();
$this->wsHandler->onOpen($connection);
$connection->resetEvents();
$this->wsHandler->onMessage($connection, new Mocks\Message([
'event' => 'websocket.ping',
'data' => new \stdClass(),
]));
$connection->assertSentEvent('websocket.pong');
}
// =========================================================================
// Protocol :response suffix
// =========================================================================
public function test_subscribe_gets_response_suffix()
{
$connection = $this->newActiveConnection(['websocket']);
$connection->resetEvents();
$this->wsHandler->onMessage($connection, new Mocks\Message([
'event' => 'websocket.subscribe',
'data' => ['channel' => 'websocket'],
]));
$response = collect($connection->sentData)->firstWhere('event', 'websocket.subscribe:response');
$this->assertNotNull($response);
$this->assertEquals('Success', $response['data']['message']);
}
public function test_unsubscribe_gets_response_suffix()
{
$connection = $this->newActiveConnection(['websocket']);
$connection->resetEvents();
$this->wsHandler->onMessage($connection, new Mocks\Message([
'event' => 'websocket.unsubscribe',
'data' => ['channel' => 'websocket'],
]));
$response = collect($connection->sentData)->firstWhere('event', 'websocket.unsubscribe:response');
$this->assertNotNull($response);
}
// =========================================================================
// Message rejection without subscription
// =========================================================================
public function test_message_to_unsubscribed_channel_gets_error()
{
$connection = $this->newActiveConnection(['websocket']);
// Unsubscribe first
$this->wsHandler->onMessage($connection, new Mocks\Message([
'event' => 'websocket.unsubscribe',
'data' => ['channel' => 'websocket'],
]));
$connection->resetEvents();
// Try to send a message to the channel
$this->wsHandler->onMessage($connection, new Mocks\Message([
'event' => 'app.someAction',
'data' => ['test' => true],
'channel' => 'websocket',
]));
$error = collect($connection->sentData)->firstWhere('event', 'app.someAction:error');
$this->assertNotNull($error);
$this->assertEquals('Subscription not established', $error['data']['message']);
}
// =========================================================================
// Connection close and error handling
// =========================================================================
public function test_on_close_removes_connection_from_channels()
{
$connection = $this->newActiveConnection(['websocket']);
$channel = $this->channelManager->find('1234', 'websocket');
$this->assertTrue($channel->hasConnection($connection));
$this->wsHandler->onClose($connection);
$this->assertFalse($channel->hasConnection($connection));
}
public function test_on_error_does_not_close_other_connections()
{
$conn1 = $this->newActiveConnection(['websocket']);
$conn2 = $this->newActiveConnection(['websocket']);
$this->wsHandler->onError($conn1, new \Exception('Test error'));
// Generic exceptions are ignored; only ExceptionsWebSocketException is emitted.
$conn1->assertNotSentEvent('websocket.error');
$conn2->assertNotSentEvent('websocket.error');
$channel = $this->channelManager->find('1234', 'websocket');
$this->assertTrue($channel->hasConnection($conn2));
}
// =========================================================================
// Broadcasting between connections (via Channel class)
// =========================================================================
public function test_broadcast_to_public_channel_reaches_all_connections()
{
$conn1 = $this->newActiveConnection(['news']);
$conn2 = $this->newActiveConnection(['news']);
$conn3 = $this->newActiveConnection(['news']);
$conn1->resetEvents();
$conn2->resetEvents();
$conn3->resetEvents();
$channel = $this->channelManager->find('1234', 'news');
$channel->broadcast('1234', (object) [
'event' => 'news.update',
'data' => ['title' => 'Breaking News'],
'channel' => 'news',
]);
$conn1->assertSentEvent('news.update');
$conn2->assertSentEvent('news.update');
$conn3->assertSentEvent('news.update');
}
public function test_broadcast_except_excludes_specified_socket()
{
$conn1 = $this->newActiveConnection(['chat']);
$conn2 = $this->newActiveConnection(['chat']);
$established = collect($conn1->sentData)->firstWhere('event', 'websocket.connection_established');
$excludeSocketId = json_decode($established['data'] ?? '{}', true)['socket_id'] ?? '';
$conn1->resetEvents();
$conn2->resetEvents();
$channel = $this->channelManager->find('1234', 'chat');
$channel->broadcastToEveryoneExcept(
(object) ['event' => 'chat.message', 'data' => ['text' => 'Hello']],
$excludeSocketId,
'1234'
);
// conn1 excluded, conn2 receives
$conn1->assertNotSentEvent('chat.message');
$conn2->assertSentEvent('chat.message');
}
// =========================================================================
// Connection count tracking
// =========================================================================
public function test_channel_connection_count_tracks_subscribe_and_unsubscribe()
{
$conn1 = $this->newActiveConnection(['counter-channel']);
$conn2 = $this->newActiveConnection(['counter-channel']);
$channel = $this->channelManager->find('1234', 'counter-channel');
$this->assertTrue($channel->hasConnection($conn1));
$this->assertTrue($channel->hasConnection($conn2));
// Unsubscribe one
$this->wsHandler->onMessage($conn1, new Mocks\Message([
'event' => 'websocket.unsubscribe',
'data' => ['channel' => 'counter-channel'],
]));
$this->assertFalse($channel->hasConnection($conn1));
$this->assertTrue($channel->hasConnection($conn2));
}
// =========================================================================
// Message without app context
// =========================================================================
public function test_message_without_on_open_is_ignored()
{
$connection = new Mocks\Connection();
$connection->httpRequest = new \GuzzleHttp\Psr7\Request('GET', '/?appKey=TestKey');
$this->wsHandler->onMessage($connection, new Mocks\Message([
'event' => 'websocket.ping',
'data' => new \stdClass(),
]));
$this->assertEmpty($connection->sentData);
}
// =========================================================================
// Re-subscription after unsubscribe
// =========================================================================
public function test_resubscribe_after_unsubscribe_works()
{
$connection = $this->newActiveConnection(['websocket']);
$channel = $this->channelManager->find('1234', 'websocket');
// Unsubscribe
$this->wsHandler->onMessage($connection, new Mocks\Message([
'event' => 'websocket.unsubscribe',
'data' => ['channel' => 'websocket'],
]));
$this->assertFalse($channel->hasConnection($connection));
// Re-subscribe
$this->wsHandler->onMessage($connection, new Mocks\Message([
'event' => 'websocket.subscribe',
'data' => ['channel' => 'websocket'],
]));
$this->assertTrue($channel->hasConnection($connection));
}
}