From f0ccd1f586d58c4225215a3f12f8cfcfc21f517e Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 2 Apr 2026 15:56:56 +0530 Subject: [PATCH 01/20] added message based query payload to realtime --- app/realtime.php | 56 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/app/realtime.php b/app/realtime.php index 67cfb19e2a..75b5ec3d59 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -960,6 +960,62 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re break; + case 'query': + // TODO: record stats + /** + * to update a query of an existing subscription for channels + * structure of the payload + * subscriptionId:"" + * channels:[] + * queries:[] + */ + if (!array_key_exists('session', $message['data'])) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.'); + } + + $store = new Store(); + + $store->decode($message['data']['session']); + + /** @var User $user */ + $user = $database->getDocument('users', $store->getProperty('id', '')); + + $roles = $user->getRoles($database->getAuthorization()); + + $payload = $message['data']; + if (!array_key_exists('subscriptionId', $payload['subscriptionId'])) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'subscriptionId is not present in payload.'); + } + if (!array_key_exists('channels', $payload)) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'channels is not present in payload.'); + } + if(!is_array($payload['channels']) || !array_is_list($payload['channels'])){ + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'channels is not a valid array.'); + } + if (!array_key_exists('queries', $payload)) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'queries is not present in payload.'); + } + + $subscriptionId = $payload['subscriptionId']; + $channels = $payload['channels']; + $queries = Query::parseQueries($payload['queries']); + + $realtime->subscribe($projectId, $connection, $subscriptionId, $roles, $channels, $queries); + + $responsePayload = json_encode([ + 'type' => 'response', + 'data' => [ + 'to' => 'query', + 'success' => true, + 'subscriptionId' => $subscriptionId, + 'channels' => $channels + ] + ]); + + $server->send([$connection], $responsePayload); + break; + + default: throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.'); } From 29b0ebb3bd6f916cd4d735ce1c36d98abd058598 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 2 Apr 2026 16:28:00 +0530 Subject: [PATCH 02/20] updated query subscription --- app/realtime.php | 66 ++++++++++++++++++++++++------------------------ 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 75b5ec3d59..f69ee428ba 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -797,7 +797,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, } }); -$server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId) { +$server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId, $app) { $project = null; $authorization = null; @@ -964,51 +964,51 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re // TODO: record stats /** * to update a query of an existing subscription for channels - * structure of the payload - * subscriptionId:"" - * channels:[] - * queries:[] + * structure of the payload -> array of maps + * 'data' : [subscriptionId:"" , channels:[] , queries:[]] */ - if (!array_key_exists('session', $message['data'])) { + if (!is_array($message['data']) || !array_is_list($message['data'])) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.'); } - $store = new Store(); + $user = $app->getResource('user'); /** @var User $user */ + $roles = $user->getRoles($authorization); - $store->decode($message['data']['session']); + // bulk validation + parsing before subscribing + foreach ($message['data'] as $payload) { + $payload = $message['data']; + if (!array_key_exists('subscriptionId', $payload['subscriptionId'])) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'subscriptionId is not present in payload.'); + } + if (!array_key_exists('channels', $payload)) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'channels is not present in payload.'); + } + if(!is_array($payload['channels']) || !array_is_list($payload['channels'])){ + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'channels is not a valid array.'); + } + if (!array_key_exists('queries', $payload)) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'queries is not present in payload.'); + } + + $subscriptionId = $payload['subscriptionId']; + $channels = $payload['channels']; + // TODO: catch error here + $payload['queries'] = Query::parseQueries($payload['queries']); + } - /** @var User $user */ - $user = $database->getDocument('users', $store->getProperty('id', '')); - - $roles = $user->getRoles($database->getAuthorization()); - - $payload = $message['data']; - if (!array_key_exists('subscriptionId', $payload['subscriptionId'])) { - throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'subscriptionId is not present in payload.'); + foreach($message['data'] as $paylod){ + $subscriptionId = $payload['subscriptionId']; + $channels = $payload['channels']; + $queries = $payload['queries']; + $realtime->subscribe($projectId, $connection, $subscriptionId, $roles, $channels, $queries); } - if (!array_key_exists('channels', $payload)) { - throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'channels is not present in payload.'); - } - if(!is_array($payload['channels']) || !array_is_list($payload['channels'])){ - throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'channels is not a valid array.'); - } - if (!array_key_exists('queries', $payload)) { - throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'queries is not present in payload.'); - } - - $subscriptionId = $payload['subscriptionId']; - $channels = $payload['channels']; - $queries = Query::parseQueries($payload['queries']); - - $realtime->subscribe($projectId, $connection, $subscriptionId, $roles, $channels, $queries); $responsePayload = json_encode([ 'type' => 'response', 'data' => [ 'to' => 'query', 'success' => true, - 'subscriptionId' => $subscriptionId, - 'channels' => $channels + 'subscriptions' => $message['data'] ] ]); From df4dbcf607706db0cabf8df21c48c32accf83ba6 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 2 Apr 2026 17:02:46 +0530 Subject: [PATCH 03/20] updated user roles --- app/realtime.php | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index f69ee428ba..77b56133ff 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -805,6 +805,8 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $rawSize = \strlen($message); $response = new Response(new SwooleResponse()); $projectId = $realtime->connections[$connection]['projectId'] ?? null; + // TODO: shall it be null or fine to have a guest role? + $roles = $realtime->connections[$connection]['roles'] ?? [Role::guests()->toString()]; // Get authorization from connection (stored during onOpen) $authorization = $realtime->connections[$connection]['authorization'] ?? null; @@ -971,38 +973,34 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.'); } - $user = $app->getResource('user'); /** @var User $user */ - $roles = $user->getRoles($authorization); - // bulk validation + parsing before subscribing foreach ($message['data'] as $payload) { - $payload = $message['data']; - if (!array_key_exists('subscriptionId', $payload['subscriptionId'])) { + if (!array_key_exists('subscriptionId', $payload)) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'subscriptionId is not present in payload.'); } if (!array_key_exists('channels', $payload)) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'channels is not present in payload.'); } - if(!is_array($payload['channels']) || !array_is_list($payload['channels'])){ + if (!is_array($payload['channels']) || !array_is_list($payload['channels'])) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'channels is not a valid array.'); } if (!array_key_exists('queries', $payload)) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'queries is not present in payload.'); } - + $subscriptionId = $payload['subscriptionId']; $channels = $payload['channels']; // TODO: catch error here $payload['queries'] = Query::parseQueries($payload['queries']); } - foreach($message['data'] as $paylod){ + foreach ($message['data'] as $paylod) { $subscriptionId = $payload['subscriptionId']; $channels = $payload['channels']; $queries = $payload['queries']; $realtime->subscribe($projectId, $connection, $subscriptionId, $roles, $channels, $queries); } - + $responsePayload = json_encode([ 'type' => 'response', 'data' => [ From d8a3b53641000c6ef9dd7a8a535989eb01b0fc56 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 2 Apr 2026 17:35:55 +0530 Subject: [PATCH 04/20] Refactor code structure for improved readability and maintainability --- .../RealtimeCustomClientQueryTest.php | 2581 +--------------- ...altimeCustomClientQueryTestWithMessage.php | 191 ++ .../Services/Realtime/RealtimeQueryBase.php | 2600 +++++++++++++++++ 3 files changed, 2794 insertions(+), 2578 deletions(-) create mode 100644 tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php create mode 100644 tests/e2e/Services/Realtime/RealtimeQueryBase.php diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php index 30cc70e981..c62e2122e2 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php @@ -2,17 +2,10 @@ namespace Tests\E2E\Services\Realtime; -use CURLFile; -use Tests\E2E\Client; use Tests\E2E\Scopes\ProjectCustom; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\SideClient; use Tests\E2E\Services\Functions\FunctionsBase; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; -use Utopia\Database\Query; -use WebSocket\TimeoutException; class RealtimeCustomClientQueryTest extends Scope { @@ -20,2578 +13,10 @@ class RealtimeCustomClientQueryTest extends Scope use RealtimeBase; use ProjectCustom; use SideClient; + use RealtimeQueryBase; - public function testAccountChannelWithQuery() + protected function supportForCheckConnectionStatus(): bool { - $user = $this->getUser(); - $userId = $user['$id'] ?? ''; - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Subscribe with query that matches current user - $client = $this->getWebsocket(['account'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::equal('$id', [$userId])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Update account name - should receive event (matches query) - $name = "Test User " . uniqid(); - $this->client->call(Client::METHOD_PATCH, '/account/name', array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ]), [ - 'name' => $name - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals($name, $event['data']['payload']['name']); - - $client->close(); - - - $user = $this->getUser(); - $userId = $user['$id'] ?? ''; - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Subscribe with query that does NOT match current user - $client = $this->getWebsocket(['account'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::notEqual('$id', [$userId])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Update account name - should NOT receive event (doesn't match query) - $name = "Test User " . uniqid(); - $this->client->call(Client::METHOD_PATCH, '/account/name', array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ]), [ - 'name' => $name - ]); - - // Should timeout - no event should be received - try { - $data = $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - } - - public function testDatabaseChannelWithQuery() - { - $user = $this->getUser(); - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'Query Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Test Collection', - 'permissions' => [ - Permission::create(Role::user($user['$id'])), - ], - 'documentSecurity' => true, - ]); - $collectionId = $collection['body']['$id']; - - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 256, - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - $targetDocumentId = ID::unique(); - - // Subscribe with query for specific document ID - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::equal('$id', [$targetDocumentId])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create document with matching ID - should receive event - $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $targetDocumentId, - 'data' => [ - 'status' => 'active' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals($targetDocumentId, $event['data']['payload']['$id']); - - // Create document with different ID - should NOT receive event - $otherDocumentId = ID::unique(); - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $otherDocumentId, - 'data' => [ - 'status' => 'inactive' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'NotEqual Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Test Collection', - 'permissions' => [ - Permission::create(Role::user($user['$id'])), - ], - 'documentSecurity' => true, - ]); - $collectionId = $collection['body']['$id']; - - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 256, - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - $excludedDocumentId = ID::unique(); - - // Subscribe with query that excludes specific document ID - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::notEqual('$id', [$excludedDocumentId])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create document with different ID - should receive event - $allowedDocumentId = ID::unique(); - $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $allowedDocumentId, - 'data' => [ - 'status' => 'active' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals($allowedDocumentId, $event['data']['payload']['$id']); - - // Create document with excluded ID - should NOT receive event - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $excludedDocumentId, - 'data' => [ - 'status' => 'inactive' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'GreaterThan Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Test Collection', - 'permissions' => [ - Permission::create(Role::user($user['$id'])), - ], - 'documentSecurity' => true, - ]); - $collectionId = $collection['body']['$id']; - - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'score', - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/score', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - // Subscribe with query for score > 50 - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::greaterThan('score', 50)->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create document with score > 50 - should receive event - $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'score' => 75 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals(75, $event['data']['payload']['score']); - - // Create document with score <= 50 - should NOT receive event - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'score' => 30 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'LesserThan Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Test Collection', - 'permissions' => [ - Permission::create(Role::user($user['$id'])), - ], - 'documentSecurity' => true, - ]); - $collectionId = $collection['body']['$id']; - - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'age', - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/age', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - // Subscribe with query for age < 18 - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::lessThan('age', 18)->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create document with age < 18 - should receive event - $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'age' => 15 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals(15, $event['data']['payload']['age']); - - // Create document with age >= 18 - should NOT receive event - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'age' => 25 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'GreaterEqual Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Test Collection', - 'permissions' => [ - Permission::create(Role::user($user['$id'])), - ], - 'documentSecurity' => true, - ]); - $collectionId = $collection['body']['$id']; - - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'priority', - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/priority', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - // Subscribe with query for priority >= 5 - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::greaterThanEqual('priority', 5)->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create document with priority = 5 - should receive event - $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'priority' => 5 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals(5, $event['data']['payload']['priority']); - - // Create document with priority > 5 - should receive event - $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'priority' => 8 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals(8, $event['data']['payload']['priority']); - - // Create document with priority < 5 - should NOT receive event - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'priority' => 3 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'LesserEqual Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Test Collection', - 'permissions' => [ - Permission::create(Role::user($user['$id'])), - ], - 'documentSecurity' => true, - ]); - $collectionId = $collection['body']['$id']; - - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'level', - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/level', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - // Subscribe with query for level <= 10 - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::lessThanEqual('level', 10)->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create document with level = 10 - should receive event - $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'level' => 10 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals(10, $event['data']['payload']['level']); - - // Create document with level < 10 - should receive event - $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'level' => 7 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals(7, $event['data']['payload']['level']); - - // Create document with level > 10 - should NOT receive event - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'level' => 15 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'IsNull Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Test Collection', - 'permissions' => [ - Permission::create(Role::user($user['$id'])), - ], - 'documentSecurity' => true, - ]); - $collectionId = $collection['body']['$id']; - - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'description', - 'size' => 256, - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/description', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - // Subscribe with query for description IS NULL - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::isNull('description')->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create document without description - should receive event - $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'description' => null - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - - // Create document with description - should NOT receive event - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'description' => 'Has description' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'IsNotNull Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Test Collection', - 'permissions' => [ - Permission::create(Role::user($user['$id'])), - ], - 'documentSecurity' => true, - ]); - $collectionId = $collection['body']['$id']; - - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'email', - 'size' => 256, - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/email', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - // Subscribe with query for email IS NOT NULL - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::isNotNull('email')->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create document with email - should receive event - $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'email' => 'test@example.com' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals('test@example.com', $event['data']['payload']['email']); - - // Create document without email - should NOT receive event - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'And Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Test Collection', - 'permissions' => [ - Permission::create(Role::user($user['$id'])), - ], - 'documentSecurity' => true, - ]); - $collectionId = $collection['body']['$id']; - - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 256, - 'required' => false, - ]); - - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'priority', - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/priority', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - // Subscribe with AND query: status = 'active' AND priority > 5 - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::and([ - Query::equal('status', ['active']), - Query::greaterThan('priority', 5) - ])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create document matching both conditions - should receive event - $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'status' => 'active', - 'priority' => 8 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals('active', $event['data']['payload']['status']); - $this->assertEquals(8, $event['data']['payload']['priority']); - - // Create document with status = 'active' but priority <= 5 - should NOT receive event - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'status' => 'active', - 'priority' => 3 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - // Create document with priority > 5 but status != 'active' - should NOT receive event - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'status' => 'inactive', - 'priority' => 9 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'Or Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Test Collection', - 'permissions' => [ - Permission::create(Role::user($user['$id'])), - ], - 'documentSecurity' => true, - ]); - $collectionId = $collection['body']['$id']; - - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'type', - 'size' => 256, - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/type', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - // Subscribe with OR query: type = 'urgent' OR type = 'critical' - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::or([ - Query::equal('type', ['urgent']), - Query::equal('type', ['critical']) - ])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create document with type = 'urgent' - should receive event - $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'type' => 'urgent' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals('urgent', $event['data']['payload']['type']); - - // Create document with type = 'critical' - should receive event - $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'type' => 'critical' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals('critical', $event['data']['payload']['type']); - - // Create document with type = 'normal' - should NOT receive event - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'type' => 'normal' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'Complex Query Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Test Collection', - 'permissions' => [ - Permission::create(Role::user($user['$id'])), - ], - 'documentSecurity' => true, - ]); - $collectionId = $collection['body']['$id']; - - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'category', - 'size' => 256, - 'required' => false, - ]); - - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'score', - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/category', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/score', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - // Subscribe with complex query: (category = 'premium' OR category = 'vip') AND score >= 80 - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::and([ - Query::or([ - Query::equal('category', ['premium']), - Query::equal('category', ['vip']) - ]), - Query::greaterThanEqual('score', 80) - ])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create document with category = 'premium' and score >= 80 - should receive event - $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'category' => 'premium', - 'score' => 85 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals('premium', $event['data']['payload']['category']); - $this->assertEquals(85, $event['data']['payload']['score']); - - // Create document with category = 'vip' and score >= 80 - should receive event - $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'category' => 'vip', - 'score' => 90 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals('vip', $event['data']['payload']['category']); - $this->assertEquals(90, $event['data']['payload']['score']); - - // Create document with category = 'premium' but score < 80 - should NOT receive event - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'category' => 'premium', - 'score' => 70 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - // Create document with score >= 80 but category != 'premium' or 'vip' - should NOT receive event - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'category' => 'standard', - 'score' => 85 - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - } - - public function testCollectionScopedDocumentsChannelReceivesEvents() - { - $user = $this->getUser(); - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'Scoped Channel DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Scoped Channel Collection', - 'permissions' => [ - Permission::create(Role::user($user['$id'])), - ], - 'documentSecurity' => true, - ]); - $collectionId = $collection['body']['$id']; - - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 256, - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - // Subscribe only to the fully-qualified documents channel for this collection - $scopedChannel = 'databases.' . $databaseId . '.collections.' . $collectionId . '.documents'; - $client = $this->getWebsocket([$scopedChannel], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - $this->assertContains($scopedChannel, $response['data']['channels']); - - // Create document in that collection - should receive event on the scoped channel - $documentId = ID::unique(); - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $documentId, - 'data' => [ - 'status' => 'active' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals($documentId, $event['data']['payload']['$id']); - - $client->close(); - } - - public function testCollectionScopedDocumentsChannelWithQuery() - { - $user = $this->getUser(); - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'Scoped Channel Query DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Scoped Channel Query Collection', - 'permissions' => [ - Permission::create(Role::user($user['$id'])), - ], - 'documentSecurity' => true, - ]); - $collectionId = $collection['body']['$id']; - - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 256, - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - $targetDocumentId = ID::unique(); - - // Subscribe with query for specific document ID on the fully-qualified documents channel - $scopedChannel = 'databases.' . $databaseId . '.collections.' . $collectionId . '.documents'; - $client = $this->getWebsocket([$scopedChannel], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::equal('$id', [$targetDocumentId])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - $this->assertContains($scopedChannel, $response['data']['channels']); - - // Create document with matching ID - should receive event - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $targetDocumentId, - 'data' => [ - 'status' => 'active' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals($targetDocumentId, $event['data']['payload']['$id']); - - // Create document with different ID - should NOT receive event - $otherDocumentId = ID::unique(); - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $otherDocumentId, - 'data' => [ - 'status' => 'inactive' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered for scoped channel query'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - } - - public function testFilesChannelWithQuery() - { - $user = $this->getUser(); - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Create bucket - $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'bucketId' => ID::unique(), - 'name' => 'Query Test Bucket', - 'permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - ] - ]); - $bucketId = $bucket['body']['$id']; - - $targetFileId = ID::unique(); - - // Subscribe with query for specific file ID - $client = $this->getWebsocket(['files'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::equal('$id', [$targetFileId])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create file with matching ID - should receive event - $file = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ - 'content-type' => 'multipart/form-data', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'fileId' => $targetFileId, - 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo.png'), - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals($targetFileId, $event['data']['payload']['$id']); - - // Create file with different ID - should NOT receive event - $otherFileId = ID::unique(); - $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ - 'content-type' => 'multipart/form-data', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'fileId' => $otherFileId, - 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo2.png'), - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - } - - public function testMultipleQueriesWithAndLogic() - { - $user = $this->getUser(); - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'Multiple Queries Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Test Collection', - 'permissions' => [ - Permission::create(Role::user($user['$id'])), - ], - 'documentSecurity' => true, - ]); - $collectionId = $collection['body']['$id']; - - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 256, - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - $targetDocId = ID::unique(); - - // Subscribe with multiple 'queries' (AND logic - ALL 'queries' must match for event to be received) - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::equal('$id', [$targetDocId])->toString(), - Query::equal('status', ['active'])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create document matching BOTH 'queries' - should receive event - $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $targetDocId, - 'data' => [ - 'status' => 'active' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals($targetDocId, $event['data']['payload']['$id']); - $this->assertEquals('active', $event['data']['payload']['status']); - - // Create document matching NEITHER query - should not receive event - // keeping it here as below are the documents created with status=>active - // so it will also receive it but the querykey can be used to distinction - $anotherDocId = ID::unique(); - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $anotherDocId, - 'data' => [ - 'status' => 'inactive' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered (neither query matches)'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - // Create document with matching ID but wrong status - should NOT receive event (only one query matches) - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $targetDocId, - 'data' => [ - 'status' => 'inactive' - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered (ID matches but status does not)'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - } - - public function testInvalidQueryShouldNotSubscribe() - { - $user = $this->getUser(); - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Test 1: Simple invalid query method (contains is not allowed) - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::contains('status', ['active'])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('error', $response['type']); - $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); - $this->assertStringContainsString('contains', $response['data']['message']); - - // Test 2: Invalid query method in nested AND query - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::and([ - Query::equal('status', ['active']), - Query::search('name', 'test') // search is not allowed - ])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('error', $response['type']); - $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); - $this->assertStringContainsString('search', $response['data']['message']); - - // Test 3: Invalid query method in nested OR query - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::or([ - Query::equal('status', ['active']), - Query::between('score', 0, 100) // between is not allowed - ])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('error', $response['type']); - $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); - $this->assertStringContainsString('between', $response['data']['message']); - - // Test 4: Deeply nested invalid query (AND -> OR -> invalid) - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::and([ - Query::equal('status', ['active']), - Query::or([ - Query::greaterThan('score', 50), - Query::startsWith('name', 'test') // startsWith is not allowed - ]) - ])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('error', $response['type']); - $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); - $this->assertStringContainsString('startsWith', $response['data']['message']); - - // Test 5: Multiple invalid 'queries' in nested structure - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::and([ - Query::contains('tags', ['important']), // contains is not allowed - Query::or([ - Query::endsWith('email', '@example.com'), // endsWith is not allowed - Query::equal('status', ['active']) - ]) - ])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('error', $response['type']); - $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); - // Should catch the first invalid method encountered - $this->assertTrue( - str_contains($response['data']['message'], 'contains') || - str_contains($response['data']['message'], 'endsWith') - ); - } - - public function testQueryKeys() - { - $user = $this->getUser(); - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'Query Keys Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Query Keys Collection', - 'permissions' => [ - Permission::create(Role::user($user['$id'])), - ], - 'documentSecurity' => true, - ]); - $collectionId = $collection['body']['$id']; - - // Attributes used by 'queries' - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 256, - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'category', - 'size' => 256, - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/category', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - $queryStatusActive = Query::equal('status', ['active'])->toString(); - $queryStatusPending = Query::equal('status', ['pending'])->toString(); - $queryComplex = Query::and([ - Query::equal('status', ['active']), - Query::equal('category', ['gold']), - ])->toString(); - - // Subscribe with no 'queries' -> should receive all events (has select("*") subscription) - $clientAll = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ]); - - // Subscribe with query1 (status == active) - $clientQ1 = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - $queryStatusActive, - ]); - - // Subscribe with query2 (status == pending) - $clientQ2 = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - $queryStatusPending, - ]); - - // Subscribe with complex query (status == active AND category == gold) - $clientComplex = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - $queryComplex, - ]); - - // All clients should be connected - foreach ([$clientAll, $clientQ1, $clientQ2, $clientComplex] as $client) { - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - } - - // 1) Create active/gold document -> should match Q1 and complex, and be seen by all - $docActiveGoldId = ID::unique(); - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $docActiveGoldId, - 'data' => [ - 'status' => 'active', - 'category' => 'gold', - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - // clientAll: should receive event, subscriptions should not be empty (has select("*") subscription that matches) - $eventAll = json_decode($clientAll->receive(), true); - $this->assertEquals('event', $eventAll['type']); - $this->assertEquals($docActiveGoldId, $eventAll['data']['payload']['$id']); - $this->assertArrayHasKey('subscriptions', $eventAll['data']); - $this->assertIsArray($eventAll['data']['subscriptions']); - // clientAll has select("*") subscription that matches all events, so subscriptions should not be empty - $this->assertNotEmpty($eventAll['data']['subscriptions']); - - // clientQ1: should receive event, subscriptions should not be empty (query matched) - $eventQ1 = json_decode($clientQ1->receive(), true); - $this->assertEquals('event', $eventQ1['type']); - $this->assertEquals($docActiveGoldId, $eventQ1['data']['payload']['$id']); - $this->assertArrayHasKey('subscriptions', $eventQ1['data']); - $this->assertIsArray($eventQ1['data']['subscriptions']); - // clientQ1 has a query that matches, so subscriptions should not be empty - $this->assertNotEmpty($eventQ1['data']['subscriptions']); - - // clientQ2: should NOT receive event (status is active, not pending) - try { - $clientQ2->receive(); - $this->fail('Expected TimeoutException - event should be filtered for clientQ2 (active document)'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - // clientComplex: should receive event, subscriptions should not be empty (query matched) - $eventComplex = json_decode($clientComplex->receive(), true); - $this->assertEquals('event', $eventComplex['type']); - $this->assertEquals($docActiveGoldId, $eventComplex['data']['payload']['$id']); - $this->assertArrayHasKey('subscriptions', $eventComplex['data']); - $this->assertIsArray($eventComplex['data']['subscriptions']); - // clientComplex has a query that matches, so subscriptions should not be empty - $this->assertNotEmpty($eventComplex['data']['subscriptions']); - - // 2) Create pending/silver document -> should match Q2 only, and be seen by all - $docPendingSilverId = ID::unique(); - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $docPendingSilverId, - 'data' => [ - 'status' => 'pending', - 'category' => 'silver', - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - // clientAll: should receive event, subscriptions should not be empty (has select("*") subscription that matches) - $eventAll2 = json_decode($clientAll->receive(), true); - $this->assertEquals('event', $eventAll2['type']); - $this->assertEquals($docPendingSilverId, $eventAll2['data']['payload']['$id']); - $this->assertArrayHasKey('subscriptions', $eventAll2['data']); - $this->assertIsArray($eventAll2['data']['subscriptions']); - // clientAll has select("*") subscription that matches all events, so subscriptions should not be empty - $this->assertNotEmpty($eventAll2['data']['subscriptions']); - - // clientQ1: should NOT receive event (status is pending) - try { - $clientQ1->receive(); - $this->fail('Expected TimeoutException - event should be filtered for clientQ1 (pending document)'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - // clientQ2: should receive event, subscriptions should not be empty (query matched) - $eventQ2 = json_decode($clientQ2->receive(), true); - $this->assertEquals('event', $eventQ2['type']); - $this->assertEquals($docPendingSilverId, $eventQ2['data']['payload']['$id']); - $this->assertArrayHasKey('subscriptions', $eventQ2['data']); - $this->assertIsArray($eventQ2['data']['subscriptions']); - // clientQ2 has a query that matches, so subscriptions should not be empty - $this->assertNotEmpty($eventQ2['data']['subscriptions']); - - // clientComplex: should NOT receive event (status is pending, category silver) - try { - $clientComplex->receive(); - $this->fail('Expected TimeoutException - event should be filtered for complex subscription (pending document)'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $clientAll->close(); - $clientQ1->close(); - $clientQ2->close(); - $clientComplex->close(); - } - - /** - * Ensure two separate subscriptions with different query keys - * only see their own matching events and expose the correct - * queryKey in queryKeys. - */ - public function testMultipleSubscriptionsDifferentQueryKeys() - { - $user = $this->getUser(); - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'Multiple Query Keys Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Multiple Query Keys Collection', - 'permissions' => [ - Permission::create(Role::user($user['$id'])), - ], - 'documentSecurity' => true, - ]); - $collectionId = $collection['body']['$id']; - - // Attribute used by 'queries' - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 256, - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - $queryStatusActive = Query::equal('status', ['active'])->toString(); - $queryStatusPending = Query::equal('status', ['pending'])->toString(); - - // Two subscriptions on the same channel with different query keys - $clientQ1 = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - $queryStatusActive, - ]); - - $clientQ2 = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - $queryStatusPending, - ]); - - // Both should connect - $response = json_decode($clientQ1->receive(), true); - $this->assertEquals('connected', $response['type']); - $response = json_decode($clientQ2->receive(), true); - $this->assertEquals('connected', $response['type']); - - // 1) active document -> only queryStatusActive subscription should see it - $docActiveId = ID::unique(); - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $docActiveId, - 'data' => [ - 'status' => 'active', - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $eventQ1 = json_decode($clientQ1->receive(), true); - $this->assertEquals('event', $eventQ1['type']); - $this->assertEquals($docActiveId, $eventQ1['data']['payload']['$id']); - $this->assertArrayHasKey('subscriptions', $eventQ1['data']); - $this->assertIsArray($eventQ1['data']['subscriptions']); - // clientQ1 has a query that matches, so subscriptions should not be empty - $this->assertNotEmpty($eventQ1['data']['subscriptions']); - - try { - $clientQ2->receive(); - $this->fail('Expected TimeoutException - clientQ2 should not receive active document'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - // 2) pending document -> only queryStatusPending subscription should see it - $docPendingId = ID::unique(); - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $docPendingId, - 'data' => [ - 'status' => 'pending', - ], - 'permissions' => [ - Permission::read(Role::any()), - ], - ]); - - $eventQ2 = json_decode($clientQ2->receive(), true); - $this->assertEquals('event', $eventQ2['type']); - $this->assertEquals($docPendingId, $eventQ2['data']['payload']['$id']); - $this->assertArrayHasKey('subscriptions', $eventQ2['data']); - $this->assertIsArray($eventQ2['data']['subscriptions']); - // clientQ2 has a query that matches, so subscriptions should not be empty - $this->assertNotEmpty($eventQ2['data']['subscriptions']); - - try { - $clientQ1->receive(); - $this->fail('Expected TimeoutException - clientQ1 should not receive pending document'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $clientQ1->close(); - $clientQ2->close(); - } - - public function testSubscriptionPreservedAfterPermissionChange() - { - $user = $this->getUser(); - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - $userId = $user['$id'] ?? ''; - - // Setup database and collection - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'Permission Change Test DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Permission Change Collection', - 'permissions' => [ - Permission::create(Role::user($userId)), - Permission::read(Role::user($userId)), - ], - 'documentSecurity' => true, - ]); - $collectionId = $collection['body']['$id']; - - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 256, - 'required' => false, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - - $targetDocumentId = ID::unique(); - - // Subscribe with query for specific document ID - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::equal('$id', [$targetDocumentId])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - $this->assertArrayHasKey('subscriptions', $response['data']); - $this->assertIsArray($response['data']['subscriptions']); - - // Store the original subscription mapping (index => subscriptionId) - $originalSubscriptionMapping = $response['data']['subscriptions']; - $this->assertNotEmpty($originalSubscriptionMapping); - // Get the first subscription ID and its index - $originalIndex = array_key_first($originalSubscriptionMapping); - $originalSubscriptionId = $originalSubscriptionMapping[$originalIndex]; - - // Create document with matching ID - should receive event - $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $targetDocumentId, - 'data' => [ - 'status' => 'active' - ], - 'permissions' => [ - Permission::read(Role::user($userId)), - Permission::update(Role::user($userId)), - ], - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals($targetDocumentId, $event['data']['payload']['$id']); - $this->assertArrayHasKey('subscriptions', $event['data']); - $this->assertContains($originalSubscriptionId, $event['data']['subscriptions']); - - // Trigger permission change by creating a team owned by a DIFFERENT user, - $teamOwnerEmail = uniqid() . 'owner@localhost.test'; - $teamOwnerPassword = 'password'; - - $teamOwner = $this->client->call(Client::METHOD_POST, '/account', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], [ - 'userId' => ID::unique(), - 'email' => $teamOwnerEmail, - 'password' => $teamOwnerPassword, - 'name' => 'Team Owner', - ]); - - $this->assertEquals(201, $teamOwner['headers']['status-code']); - - $teamOwnerSession = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], [ - 'email' => $teamOwnerEmail, - 'password' => $teamOwnerPassword, - ]); - - $teamOwnerSession = $teamOwnerSession['cookies']['a_session_' . $projectId] ?? ''; - - $team = $this->client->call(Client::METHOD_POST, '/teams', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'cookie' => 'a_session_' . $projectId . '=' . $teamOwnerSession, - ], [ - 'teamId' => ID::unique(), - 'name' => 'Test Team', - ]); - $teamId = $team['body']['$id']; - - $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ], [ - 'email' => $user['email'], - 'roles' => ['member'], - 'url' => 'http://localhost', - ]); - - sleep(1); - - // Verify subscription is still working after permission change - $nonMatchingDocumentId = ID::unique(); - $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $nonMatchingDocumentId, - 'data' => [ - 'status' => 'active' - ], - 'permissions' => [ - Permission::read(Role::user($userId)), - Permission::update(Role::user($userId)), - ], - ]); - - // This document doesn't match the query, so we shouldn't receive it - try { - $data = $client->receive(); - $this->fail('Expected TimeoutException - document does not match query after permission change'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - // Create a NEW document with a different ID - should NOT receive event - $targetDocumentId2 = ID::unique(); - $document3 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'documentId' => $targetDocumentId2, - 'data' => [ - 'status' => 'active' - ], - 'permissions' => [ - Permission::read(Role::user($userId)), - Permission::update(Role::user($userId)), - ], - ]); - - sleep(1); - - // This should NOT receive event because the query is for $targetDocumentId, not $targetDocumentId2 - // This verifies the query is preserved after permission change - try { - $data = $client->receive(); - $this->fail('Expected TimeoutException - new document does not match original query after permission change'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - // Create a document with the ORIGINAL matching ID - should receive event - $document4 = $this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents/' . $targetDocumentId, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'data' => [ - 'status' => 'updated-after-permission-change' - ], - ]); - - // Wait a bit for the event to be processed - sleep(1); - - // Verify the event is received with the preserved subscription - $event2 = json_decode($client->receive(), true); - $this->assertEquals('event', $event2['type']); - $this->assertEquals($targetDocumentId, $event2['data']['payload']['$id']); - $this->assertEquals('updated-after-permission-change', $event2['data']['payload']['status']); - $this->assertArrayHasKey('subscriptions', $event2['data']); - $this->assertIsArray($event2['data']['subscriptions']); - $this->assertNotEmpty($event2['data']['subscriptions']); - // Subscription ID should remain stable after permission change - $this->assertContains($originalSubscriptionId, $event2['data']['subscriptions']); - - $client->close(); - } - - public function testProjectChannelWithQuery() - { - $user = $this->getUser(); - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Test OLD SDK behavior: project=projectId (string) in query param - // For reserved \"project\" param, string is treated as routing-only (project ID), - // and is not used as queries for the project channel. We should fall back to select(*). - $clientOldSdk = $this->getWebsocket(['project'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], $projectId, null); - - $response = json_decode($clientOldSdk->receive(), true); - $this->assertEquals('connected', $response['type']); - $this->assertContains('project', $response['data']['channels']); - // Should have default select(['*']) subscription since project param was treated as project ID, not queries - $this->assertArrayHasKey('subscriptions', $response['data']); - $this->assertIsArray($response['data']['subscriptions']); - $this->assertNotEmpty($response['data']['subscriptions']); - - $clientOldSdk->close(); - - // Test NEW SDK behavior: project=Query array in query param, project ID in header - // The reserved param logic should use Query array as subscription queries for project channel - $queryArray = [Query::select(['*'])->toString()]; - $clientNewSdk = $this->getWebsocketWithCustomQuery( - [ - 'channels' => ['project'], - 'project' => [ - 0 => [ - 0 => $queryArray[0] - ] - ] - ], - [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - 'x-appwrite-project' => $projectId, - ] - ); - - $response = json_decode($clientNewSdk->receive(), true); - $this->assertEquals('connected', $response['type']); - $this->assertContains('project', $response['data']['channels']); - // Should have subscription with the provided query - $this->assertArrayHasKey('subscriptions', $response['data']); - $this->assertIsArray($response['data']['subscriptions']); - $this->assertNotEmpty($response['data']['subscriptions']); - - $clientNewSdk->close(); - - // Test edge case: project param is array but not a valid Query array - // This should now fail with an invalid query error rather than silently falling back. - $clientEdgeCase = $this->getWebsocketWithCustomQuery( - [ - 'channels' => ['project'], - 'project' => ['invalid', 'array'] - ], - [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - 'x-appwrite-project' => $projectId, - ] - ); - - $response = json_decode($clientEdgeCase->receive(), true); - $this->assertEquals('error', $response['type']); - $this->assertStringContainsString('Invalid query', $response['data']['message']); - } - - public function testProjectChannelWithHeaderOnly() - { - $user = $this->getUser(); - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Test: project ID only in header, no project query param - // This simulates a client that only uses x-appwrite-project header - $client = $this->getWebsocketWithCustomQuery( - [ - 'channels' => ['project'] - ], - [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - 'x-appwrite-project' => $projectId, - ] - ); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - $this->assertContains('project', $response['data']['channels']); - // Should have default select(['*']) subscription since no project query param - $this->assertArrayHasKey('subscriptions', $response['data']); - $this->assertIsArray($response['data']['subscriptions']); - $this->assertNotEmpty($response['data']['subscriptions']); - - $client->close(); - - // Test: project channel with queries, project ID only in header - $queryArray = [Query::select(['*'])->toString()]; - $clientWithQuery = $this->getWebsocketWithCustomQuery( - [ - 'channels' => ['project'], - 'project' => [ - 0 => [ - 0 => $queryArray[0] - ] - ] - ], - [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - 'x-appwrite-project' => $projectId, - ] - ); - - $response = json_decode($clientWithQuery->receive(), true); - $this->assertEquals('connected', $response['type']); - $this->assertContains('project', $response['data']['channels']); - $this->assertArrayHasKey('subscriptions', $response['data']); - $this->assertIsArray($response['data']['subscriptions']); - $this->assertNotEmpty($response['data']['subscriptions']); - - $clientWithQuery->close(); - } - - public function testTestsChannelWithQueries() - { - $projectId = 'console'; - - // Subscribe without queries - should receive all events - $clientNoQuery = $this->getWebsocket( - channels: ['tests'], - headers: ['origin' => 'http://localhost'], - projectId: $projectId, - timeout: 5 - ); - - $response = json_decode($clientNoQuery->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Subscribe with matching query - should receive events - $clientWithMatchingQuery = $this->getWebsocket( - channels: ['tests'], - headers: ['origin' => 'http://localhost'], - projectId: $projectId, - queries: [Query::equal('response', ['WS:/v1/realtime:passed'])->toString()], - timeout: 5 - ); - - $response = json_decode($clientWithMatchingQuery->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Subscribe with non-matching query - should NOT receive events - $clientWithNonMatchingQuery = $this->getWebsocket( - channels: ['tests'], - headers: ['origin' => 'http://localhost'], - projectId: $projectId, - queries: [Query::equal('response', ['failed'])->toString()] - ); - - $response = json_decode($clientWithNonMatchingQuery->receive(), true); - $this->assertEquals('connected', $response['type']); - - sleep(2); - - // Client without query should receive event - $eventNoQuery = json_decode($clientNoQuery->receive(), true); - $this->assertEquals('event', $eventNoQuery['type']); - $this->assertEquals('test.event', $eventNoQuery['data']['events'][0]); - $this->assertEquals('WS:/v1/realtime:passed', $eventNoQuery['data']['payload']['response']); - - // Client with matching query should receive event - $eventMatching = json_decode($clientWithMatchingQuery->receive(), true); - $this->assertEquals('event', $eventMatching['type']); - $this->assertEquals('test.event', $eventMatching['data']['events'][0]); - $this->assertEquals('WS:/v1/realtime:passed', $eventMatching['data']['payload']['response']); - - // Client with non-matching query should NOT receive event - try { - $clientWithNonMatchingQuery->receive(); - $this->fail('Expected TimeoutException - client with non-matching query should not receive event'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $clientNoQuery->close(); - $clientWithMatchingQuery->close(); - $clientWithNonMatchingQuery->close(); + return true; } } diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php new file mode 100644 index 0000000000..b1de21d455 --- /dev/null +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php @@ -0,0 +1,191 @@ +getProject()['$id']; + } + + $queryString = \http_build_query([ + 'project' => $projectId, + 'channels' => $channels, + ]); + + $client = new WebSocketClient( + 'ws://appwrite.test/v1/realtime?' . $queryString, + [ + 'headers' => $headers, + 'timeout' => $timeout, + ] + ); + $connected = \json_decode($client->receive(), true); + $this->assertEquals('connected', $connected['type'] ?? null); + + if ($queries === null) { + return $client; + } + + $subscriptions = $connected['data']['subscriptions'] ?? []; + $this->assertNotEmpty($subscriptions); + $subscriptionId = $subscriptions[\array_key_first($subscriptions)]; + + if ($queries === []) { + $queries = [Query::select(['*'])->toString()]; + } + + $client->send(\json_encode([ + 'type' => 'query', + 'data' => [[ + 'subscriptionId' => $subscriptionId, + 'channels' => $channels, + 'queries' => $queries, + ]], + ])); + + $response = \json_decode($client->receive(), true); + $this->assertEquals('response', $response['type'] ?? null); + $this->assertEquals('query', $response['data']['to'] ?? null); + $this->assertTrue($response['data']['success'] ?? false); + $this->assertArrayHasKey('subscriptions', $response['data']); + $this->assertIsArray($response['data']['subscriptions']); + + return $client; + } + + public function testQueryMessageFiltersEvents(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $userId = $user['$id'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Query Message Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Query Message Test Collection', + 'permissions' => [ + Permission::create(Role::user($userId)), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + $targetDocumentId = ID::unique(); + $otherDocumentId = ID::unique(); + + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$targetDocumentId])->toString(), + ]); + + // Create matching document - should receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $targetDocumentId, + 'data' => [ + 'status' => 'active', + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($targetDocumentId, $event['data']['payload']['$id']); + + // Create non-matching document - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $otherDocumentId, + 'data' => [ + 'status' => 'inactive', + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered by updated query'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } +} diff --git a/tests/e2e/Services/Realtime/RealtimeQueryBase.php b/tests/e2e/Services/Realtime/RealtimeQueryBase.php new file mode 100644 index 0000000000..c72886b3dc --- /dev/null +++ b/tests/e2e/Services/Realtime/RealtimeQueryBase.php @@ -0,0 +1,2600 @@ +supportForCheckConnectionStatus()) { + return null; + } + + $response = json_decode($client->receive(), true); + $this->assertSame('connected', $response['type']); + return $response; + } + + public function testAccountChannelWithQuery() + { + $user = $this->getUser(); + $userId = $user['$id'] ?? ''; + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Subscribe with query that matches current user + $client = $this->getWebsocket(['account'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$userId])->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Update account name - should receive event (matches query) + $name = "Test User " . uniqid(); + $this->client->call(Client::METHOD_PATCH, '/account/name', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]), [ + 'name' => $name + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($name, $event['data']['payload']['name']); + + $client->close(); + + + $user = $this->getUser(); + $userId = $user['$id'] ?? ''; + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Subscribe with query that does NOT match current user + $client = $this->getWebsocket(['account'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::notEqual('$id', [$userId])->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Update account name - should NOT receive event (doesn't match query) + $name = "Test User " . uniqid(); + $this->client->call(Client::METHOD_PATCH, '/account/name', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]), [ + 'name' => $name + ]); + + // Should timeout - no event should be received + try { + $data = $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } + + public function testDatabaseChannelWithQuery() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Query Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + $targetDocumentId = ID::unique(); + + // Subscribe with query for specific document ID + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$targetDocumentId])->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Create document with matching ID - should receive event + $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $targetDocumentId, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($targetDocumentId, $event['data']['payload']['$id']); + + // Create document with different ID - should NOT receive event + $otherDocumentId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $otherDocumentId, + 'data' => [ + 'status' => 'inactive' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'NotEqual Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + $excludedDocumentId = ID::unique(); + + // Subscribe with query that excludes specific document ID + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::notEqual('$id', [$excludedDocumentId])->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Create document with different ID - should receive event + $allowedDocumentId = ID::unique(); + $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $allowedDocumentId, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($allowedDocumentId, $event['data']['payload']['$id']); + + // Create document with excluded ID - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $excludedDocumentId, + 'data' => [ + 'status' => 'inactive' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'GreaterThan Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'score', + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/score', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + // Subscribe with query for score > 50 + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::greaterThan('score', 50)->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Create document with score > 50 - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'score' => 75 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals(75, $event['data']['payload']['score']); + + // Create document with score <= 50 - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'score' => 30 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'LesserThan Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'age', + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/age', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + // Subscribe with query for age < 18 + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::lessThan('age', 18)->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Create document with age < 18 - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'age' => 15 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals(15, $event['data']['payload']['age']); + + // Create document with age >= 18 - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'age' => 25 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'GreaterEqual Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'priority', + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/priority', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + // Subscribe with query for priority >= 5 + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::greaterThanEqual('priority', 5)->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Create document with priority = 5 - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'priority' => 5 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals(5, $event['data']['payload']['priority']); + + // Create document with priority > 5 - should receive event + $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'priority' => 8 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals(8, $event['data']['payload']['priority']); + + // Create document with priority < 5 - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'priority' => 3 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'LesserEqual Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'level', + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/level', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + // Subscribe with query for level <= 10 + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::lessThanEqual('level', 10)->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Create document with level = 10 - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'level' => 10 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals(10, $event['data']['payload']['level']); + + // Create document with level < 10 - should receive event + $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'level' => 7 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals(7, $event['data']['payload']['level']); + + // Create document with level > 10 - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'level' => 15 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'IsNull Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'description', + 'size' => 256, + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/description', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + // Subscribe with query for description IS NULL + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::isNull('description')->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Create document without description - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'description' => null + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + + // Create document with description - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'description' => 'Has description' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'IsNotNull Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'email', + 'size' => 256, + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/email', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + // Subscribe with query for email IS NOT NULL + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::isNotNull('email')->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Create document with email - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'email' => 'test@example.com' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals('test@example.com', $event['data']['payload']['email']); + + // Create document without email - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'And Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'priority', + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/priority', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + // Subscribe with AND query: status = 'active' AND priority > 5 + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::and([ + Query::equal('status', ['active']), + Query::greaterThan('priority', 5) + ])->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Create document matching both conditions - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'status' => 'active', + 'priority' => 8 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals('active', $event['data']['payload']['status']); + $this->assertEquals(8, $event['data']['payload']['priority']); + + // Create document with status = 'active' but priority <= 5 - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'status' => 'active', + 'priority' => 3 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + // Create document with priority > 5 but status != 'active' - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'status' => 'inactive', + 'priority' => 9 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Or Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'type', + 'size' => 256, + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/type', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + // Subscribe with OR query: type = 'urgent' OR type = 'critical' + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::or([ + Query::equal('type', ['urgent']), + Query::equal('type', ['critical']) + ])->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Create document with type = 'urgent' - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'type' => 'urgent' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals('urgent', $event['data']['payload']['type']); + + // Create document with type = 'critical' - should receive event + $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'type' => 'critical' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals('critical', $event['data']['payload']['type']); + + // Create document with type = 'normal' - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'type' => 'normal' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Complex Query Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'category', + 'size' => 256, + 'required' => false, + ]); + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'score', + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/category', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/score', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + // Subscribe with complex query: (category = 'premium' OR category = 'vip') AND score >= 80 + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::and([ + Query::or([ + Query::equal('category', ['premium']), + Query::equal('category', ['vip']) + ]), + Query::greaterThanEqual('score', 80) + ])->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Create document with category = 'premium' and score >= 80 - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'category' => 'premium', + 'score' => 85 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals('premium', $event['data']['payload']['category']); + $this->assertEquals(85, $event['data']['payload']['score']); + + // Create document with category = 'vip' and score >= 80 - should receive event + $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'category' => 'vip', + 'score' => 90 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals('vip', $event['data']['payload']['category']); + $this->assertEquals(90, $event['data']['payload']['score']); + + // Create document with category = 'premium' but score < 80 - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'category' => 'premium', + 'score' => 70 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + // Create document with score >= 80 but category != 'premium' or 'vip' - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'category' => 'standard', + 'score' => 85 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } + + public function testCollectionScopedDocumentsChannelReceivesEvents() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Scoped Channel DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Scoped Channel Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + // Subscribe only to the fully-qualified documents channel for this collection + $scopedChannel = 'databases.' . $databaseId . '.collections.' . $collectionId . '.documents'; + $client = $this->getWebsocket([$scopedChannel], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]); + + $response = $this->assertConnectionStatusIfSupported($client); + if ($response !== null) { + $this->assertContains($scopedChannel, $response['data']['channels']); + } + + // Create document in that collection - should receive event on the scoped channel + $documentId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $documentId, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($documentId, $event['data']['payload']['$id']); + + $client->close(); + } + + public function testCollectionScopedDocumentsChannelWithQuery() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Scoped Channel Query DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Scoped Channel Query Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + $targetDocumentId = ID::unique(); + + // Subscribe with query for specific document ID on the fully-qualified documents channel + $scopedChannel = 'databases.' . $databaseId . '.collections.' . $collectionId . '.documents'; + $client = $this->getWebsocket([$scopedChannel], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$targetDocumentId])->toString(), + ]); + + $response = $this->assertConnectionStatusIfSupported($client); + if ($response !== null) { + $this->assertContains($scopedChannel, $response['data']['channels']); + } + + // Create document with matching ID - should receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $targetDocumentId, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($targetDocumentId, $event['data']['payload']['$id']); + + // Create document with different ID - should NOT receive event + $otherDocumentId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $otherDocumentId, + 'data' => [ + 'status' => 'inactive' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered for scoped channel query'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } + + public function testFilesChannelWithQuery() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Create bucket + $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'bucketId' => ID::unique(), + 'name' => 'Query Test Bucket', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ] + ]); + $bucketId = $bucket['body']['$id']; + + $targetFileId = ID::unique(); + + // Subscribe with query for specific file ID + $client = $this->getWebsocket(['files'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$targetFileId])->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Create file with matching ID - should receive event + $file = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'fileId' => $targetFileId, + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo.png'), + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($targetFileId, $event['data']['payload']['$id']); + + // Create file with different ID - should NOT receive event + $otherFileId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'fileId' => $otherFileId, + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo2.png'), + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } + + public function testMultipleQueriesWithAndLogic() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Multiple Queries Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + $targetDocId = ID::unique(); + + // Subscribe with multiple 'queries' (AND logic - ALL 'queries' must match for event to be received) + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$targetDocId])->toString(), + Query::equal('status', ['active'])->toString(), + ]); + + $this->assertConnectionStatusIfSupported($client); + + // Create document matching BOTH 'queries' - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $targetDocId, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($targetDocId, $event['data']['payload']['$id']); + $this->assertEquals('active', $event['data']['payload']['status']); + + // Create document matching NEITHER query - should not receive event + // keeping it here as below are the documents created with status=>active + // so it will also receive it but the querykey can be used to distinction + $anotherDocId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $anotherDocId, + 'data' => [ + 'status' => 'inactive' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered (neither query matches)'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + // Create document with matching ID but wrong status - should NOT receive event (only one query matches) + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $targetDocId, + 'data' => [ + 'status' => 'inactive' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered (ID matches but status does not)'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } + + public function testInvalidQueryShouldNotSubscribe() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Test 1: Simple invalid query method (contains is not allowed) + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::contains('status', ['active'])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + $this->assertStringContainsString('contains', $response['data']['message']); + + // Test 2: Invalid query method in nested AND query + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::and([ + Query::equal('status', ['active']), + Query::search('name', 'test') // search is not allowed + ])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + $this->assertStringContainsString('search', $response['data']['message']); + + // Test 3: Invalid query method in nested OR query + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::or([ + Query::equal('status', ['active']), + Query::between('score', 0, 100) // between is not allowed + ])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + $this->assertStringContainsString('between', $response['data']['message']); + + // Test 4: Deeply nested invalid query (AND -> OR -> invalid) + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::and([ + Query::equal('status', ['active']), + Query::or([ + Query::greaterThan('score', 50), + Query::startsWith('name', 'test') // startsWith is not allowed + ]) + ])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + $this->assertStringContainsString('startsWith', $response['data']['message']); + + // Test 5: Multiple invalid 'queries' in nested structure + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::and([ + Query::contains('tags', ['important']), // contains is not allowed + Query::or([ + Query::endsWith('email', '@example.com'), // endsWith is not allowed + Query::equal('status', ['active']) + ]) + ])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + // Should catch the first invalid method encountered + $this->assertTrue( + str_contains($response['data']['message'], 'contains') || + str_contains($response['data']['message'], 'endsWith') + ); + } + + public function testQueryKeys() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Query Keys Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Query Keys Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + // Attributes used by 'queries' + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'category', + 'size' => 256, + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/category', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + $queryStatusActive = Query::equal('status', ['active'])->toString(); + $queryStatusPending = Query::equal('status', ['pending'])->toString(); + $queryComplex = Query::and([ + Query::equal('status', ['active']), + Query::equal('category', ['gold']), + ])->toString(); + + // Subscribe with no 'queries' -> should receive all events (has select("*") subscription) + $clientAll = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]); + + // Subscribe with query1 (status == active) + $clientQ1 = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + $queryStatusActive, + ]); + + // Subscribe with query2 (status == pending) + $clientQ2 = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + $queryStatusPending, + ]); + + // Subscribe with complex query (status == active AND category == gold) + $clientComplex = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + $queryComplex, + ]); + + // All clients should be connected + foreach ([$clientAll, $clientQ1, $clientQ2, $clientComplex] as $client) { + $this->assertConnectionStatusIfSupported($client); + } + + // 1) Create active/gold document -> should match Q1 and complex, and be seen by all + $docActiveGoldId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $docActiveGoldId, + 'data' => [ + 'status' => 'active', + 'category' => 'gold', + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + // clientAll: should receive event, subscriptions should not be empty (has select("*") subscription that matches) + $eventAll = json_decode($clientAll->receive(), true); + $this->assertEquals('event', $eventAll['type']); + $this->assertEquals($docActiveGoldId, $eventAll['data']['payload']['$id']); + $this->assertArrayHasKey('subscriptions', $eventAll['data']); + $this->assertIsArray($eventAll['data']['subscriptions']); + // clientAll has select("*") subscription that matches all events, so subscriptions should not be empty + $this->assertNotEmpty($eventAll['data']['subscriptions']); + + // clientQ1: should receive event, subscriptions should not be empty (query matched) + $eventQ1 = json_decode($clientQ1->receive(), true); + $this->assertEquals('event', $eventQ1['type']); + $this->assertEquals($docActiveGoldId, $eventQ1['data']['payload']['$id']); + $this->assertArrayHasKey('subscriptions', $eventQ1['data']); + $this->assertIsArray($eventQ1['data']['subscriptions']); + // clientQ1 has a query that matches, so subscriptions should not be empty + $this->assertNotEmpty($eventQ1['data']['subscriptions']); + + // clientQ2: should NOT receive event (status is active, not pending) + try { + $clientQ2->receive(); + $this->fail('Expected TimeoutException - event should be filtered for clientQ2 (active document)'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + // clientComplex: should receive event, subscriptions should not be empty (query matched) + $eventComplex = json_decode($clientComplex->receive(), true); + $this->assertEquals('event', $eventComplex['type']); + $this->assertEquals($docActiveGoldId, $eventComplex['data']['payload']['$id']); + $this->assertArrayHasKey('subscriptions', $eventComplex['data']); + $this->assertIsArray($eventComplex['data']['subscriptions']); + // clientComplex has a query that matches, so subscriptions should not be empty + $this->assertNotEmpty($eventComplex['data']['subscriptions']); + + // 2) Create pending/silver document -> should match Q2 only, and be seen by all + $docPendingSilverId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $docPendingSilverId, + 'data' => [ + 'status' => 'pending', + 'category' => 'silver', + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + // clientAll: should receive event, subscriptions should not be empty (has select("*") subscription that matches) + $eventAll2 = json_decode($clientAll->receive(), true); + $this->assertEquals('event', $eventAll2['type']); + $this->assertEquals($docPendingSilverId, $eventAll2['data']['payload']['$id']); + $this->assertArrayHasKey('subscriptions', $eventAll2['data']); + $this->assertIsArray($eventAll2['data']['subscriptions']); + // clientAll has select("*") subscription that matches all events, so subscriptions should not be empty + $this->assertNotEmpty($eventAll2['data']['subscriptions']); + + // clientQ1: should NOT receive event (status is pending) + try { + $clientQ1->receive(); + $this->fail('Expected TimeoutException - event should be filtered for clientQ1 (pending document)'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + // clientQ2: should receive event, subscriptions should not be empty (query matched) + $eventQ2 = json_decode($clientQ2->receive(), true); + $this->assertEquals('event', $eventQ2['type']); + $this->assertEquals($docPendingSilverId, $eventQ2['data']['payload']['$id']); + $this->assertArrayHasKey('subscriptions', $eventQ2['data']); + $this->assertIsArray($eventQ2['data']['subscriptions']); + // clientQ2 has a query that matches, so subscriptions should not be empty + $this->assertNotEmpty($eventQ2['data']['subscriptions']); + + // clientComplex: should NOT receive event (status is pending, category silver) + try { + $clientComplex->receive(); + $this->fail('Expected TimeoutException - event should be filtered for complex subscription (pending document)'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $clientAll->close(); + $clientQ1->close(); + $clientQ2->close(); + $clientComplex->close(); + } + + /** + * Ensure two separate subscriptions with different query keys + * only see their own matching events and expose the correct + * queryKey in queryKeys. + */ + public function testMultipleSubscriptionsDifferentQueryKeys() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Multiple Query Keys Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Multiple Query Keys Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + // Attribute used by 'queries' + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + $queryStatusActive = Query::equal('status', ['active'])->toString(); + $queryStatusPending = Query::equal('status', ['pending'])->toString(); + + // Two subscriptions on the same channel with different query keys + $clientQ1 = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + $queryStatusActive, + ]); + + $clientQ2 = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + $queryStatusPending, + ]); + + // Both should connect + $this->assertConnectionStatusIfSupported($clientQ1); + $this->assertConnectionStatusIfSupported($clientQ2); + + // 1) active document -> only queryStatusActive subscription should see it + $docActiveId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $docActiveId, + 'data' => [ + 'status' => 'active', + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $eventQ1 = json_decode($clientQ1->receive(), true); + $this->assertEquals('event', $eventQ1['type']); + $this->assertEquals($docActiveId, $eventQ1['data']['payload']['$id']); + $this->assertArrayHasKey('subscriptions', $eventQ1['data']); + $this->assertIsArray($eventQ1['data']['subscriptions']); + // clientQ1 has a query that matches, so subscriptions should not be empty + $this->assertNotEmpty($eventQ1['data']['subscriptions']); + + try { + $clientQ2->receive(); + $this->fail('Expected TimeoutException - clientQ2 should not receive active document'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + // 2) pending document -> only queryStatusPending subscription should see it + $docPendingId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $docPendingId, + 'data' => [ + 'status' => 'pending', + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $eventQ2 = json_decode($clientQ2->receive(), true); + $this->assertEquals('event', $eventQ2['type']); + $this->assertEquals($docPendingId, $eventQ2['data']['payload']['$id']); + $this->assertArrayHasKey('subscriptions', $eventQ2['data']); + $this->assertIsArray($eventQ2['data']['subscriptions']); + // clientQ2 has a query that matches, so subscriptions should not be empty + $this->assertNotEmpty($eventQ2['data']['subscriptions']); + + try { + $clientQ1->receive(); + $this->fail('Expected TimeoutException - clientQ1 should not receive pending document'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $clientQ1->close(); + $clientQ2->close(); + } + + public function testSubscriptionPreservedAfterPermissionChange() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + $userId = $user['$id'] ?? ''; + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Permission Change Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Permission Change Collection', + 'permissions' => [ + Permission::create(Role::user($userId)), + Permission::read(Role::user($userId)), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId, $projectId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/status', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + $targetDocumentId = ID::unique(); + + // Subscribe with query for specific document ID + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$targetDocumentId])->toString(), + ]); + + $originalSubscriptionId = null; + $response = $this->assertConnectionStatusIfSupported($client); + if ($response !== null) { + $this->assertArrayHasKey('subscriptions', $response['data']); + $this->assertIsArray($response['data']['subscriptions']); + + // Store the original subscription mapping (index => subscriptionId) + $originalSubscriptionMapping = $response['data']['subscriptions']; + $this->assertNotEmpty($originalSubscriptionMapping); + // Get the first subscription ID and its index + $originalIndex = array_key_first($originalSubscriptionMapping); + $originalSubscriptionId = $originalSubscriptionMapping[$originalIndex]; + } + + // Create document with matching ID - should receive event + $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $targetDocumentId, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::user($userId)), + Permission::update(Role::user($userId)), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($targetDocumentId, $event['data']['payload']['$id']); + if ($originalSubscriptionId !== null) { + $this->assertArrayHasKey('subscriptions', $event['data']); + $this->assertContains($originalSubscriptionId, $event['data']['subscriptions']); + } + + // Trigger permission change by creating a team owned by a DIFFERENT user, + $teamOwnerEmail = uniqid() . 'owner@localhost.test'; + $teamOwnerPassword = 'password'; + + $teamOwner = $this->client->call(Client::METHOD_POST, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'userId' => ID::unique(), + 'email' => $teamOwnerEmail, + 'password' => $teamOwnerPassword, + 'name' => 'Team Owner', + ]); + + $this->assertEquals(201, $teamOwner['headers']['status-code']); + + $teamOwnerSession = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $teamOwnerEmail, + 'password' => $teamOwnerPassword, + ]); + + $teamOwnerSession = $teamOwnerSession['cookies']['a_session_' . $projectId] ?? ''; + + $team = $this->client->call(Client::METHOD_POST, '/teams', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $teamOwnerSession, + ], [ + 'teamId' => ID::unique(), + 'name' => 'Test Team', + ]); + $teamId = $team['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'email' => $user['email'], + 'roles' => ['member'], + 'url' => 'http://localhost', + ]); + + sleep(1); + + // Verify subscription is still working after permission change + $nonMatchingDocumentId = ID::unique(); + $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $nonMatchingDocumentId, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::user($userId)), + Permission::update(Role::user($userId)), + ], + ]); + + // This document doesn't match the query, so we shouldn't receive it + try { + $data = $client->receive(); + $this->fail('Expected TimeoutException - document does not match query after permission change'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + // Create a NEW document with a different ID - should NOT receive event + $targetDocumentId2 = ID::unique(); + $document3 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $targetDocumentId2, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::user($userId)), + Permission::update(Role::user($userId)), + ], + ]); + + sleep(1); + + // This should NOT receive event because the query is for $targetDocumentId, not $targetDocumentId2 + // This verifies the query is preserved after permission change + try { + $data = $client->receive(); + $this->fail('Expected TimeoutException - new document does not match original query after permission change'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + // Create a document with the ORIGINAL matching ID - should receive event + $document4 = $this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents/' . $targetDocumentId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'data' => [ + 'status' => 'updated-after-permission-change' + ], + ]); + + // Wait a bit for the event to be processed + sleep(1); + + // Verify the event is received with the preserved subscription + $event2 = json_decode($client->receive(), true); + $this->assertEquals('event', $event2['type']); + $this->assertEquals($targetDocumentId, $event2['data']['payload']['$id']); + $this->assertEquals('updated-after-permission-change', $event2['data']['payload']['status']); + $this->assertArrayHasKey('subscriptions', $event2['data']); + $this->assertIsArray($event2['data']['subscriptions']); + $this->assertNotEmpty($event2['data']['subscriptions']); + // Subscription ID should remain stable after permission change + $this->assertContains($originalSubscriptionId, $event2['data']['subscriptions']); + + $client->close(); + } + + public function testProjectChannelWithQuery() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Test OLD SDK behavior: project=projectId (string) in query param + // For reserved \"project\" param, string is treated as routing-only (project ID), + // and is not used as queries for the project channel. We should fall back to select(*). + $clientOldSdk = $this->getWebsocket(['project'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], $projectId, null); + + $response = $this->assertConnectionStatusIfSupported($clientOldSdk); + if ($response !== null) { + $this->assertContains('project', $response['data']['channels']); + // Should have default select(['*']) subscription since project param was treated as project ID, not queries + $this->assertArrayHasKey('subscriptions', $response['data']); + $this->assertIsArray($response['data']['subscriptions']); + $this->assertNotEmpty($response['data']['subscriptions']); + } + + $clientOldSdk->close(); + + // Test NEW SDK behavior: project=Query array in query param, project ID in header + // The reserved param logic should use Query array as subscription queries for project channel + $queryArray = [Query::select(['*'])->toString()]; + $clientNewSdk = $this->getWebsocketWithCustomQuery( + [ + 'channels' => ['project'], + 'project' => [ + 0 => [ + 0 => $queryArray[0] + ] + ] + ], + [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + 'x-appwrite-project' => $projectId, + ] + ); + + $response = $this->assertConnectionStatusIfSupported($clientNewSdk); + if ($response !== null) { + $this->assertContains('project', $response['data']['channels']); + // Should have subscription with the provided query + $this->assertArrayHasKey('subscriptions', $response['data']); + $this->assertIsArray($response['data']['subscriptions']); + $this->assertNotEmpty($response['data']['subscriptions']); + } + + $clientNewSdk->close(); + + // Test edge case: project param is array but not a valid Query array + // This should now fail with an invalid query error rather than silently falling back. + $clientEdgeCase = $this->getWebsocketWithCustomQuery( + [ + 'channels' => ['project'], + 'project' => ['invalid', 'array'] + ], + [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + 'x-appwrite-project' => $projectId, + ] + ); + + $response = json_decode($clientEdgeCase->receive(), true); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('Invalid query', $response['data']['message']); + } + + public function testProjectChannelWithHeaderOnly() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Test: project ID only in header, no project query param + // This simulates a client that only uses x-appwrite-project header + $client = $this->getWebsocketWithCustomQuery( + [ + 'channels' => ['project'] + ], + [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + 'x-appwrite-project' => $projectId, + ] + ); + + $response = $this->assertConnectionStatusIfSupported($client); + if ($response !== null) { + $this->assertContains('project', $response['data']['channels']); + // Should have default select(['*']) subscription since no project query param + $this->assertArrayHasKey('subscriptions', $response['data']); + $this->assertIsArray($response['data']['subscriptions']); + $this->assertNotEmpty($response['data']['subscriptions']); + } + + $client->close(); + + // Test: project channel with queries, project ID only in header + $queryArray = [Query::select(['*'])->toString()]; + $clientWithQuery = $this->getWebsocketWithCustomQuery( + [ + 'channels' => ['project'], + 'project' => [ + 0 => [ + 0 => $queryArray[0] + ] + ] + ], + [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + 'x-appwrite-project' => $projectId, + ] + ); + + $response = $this->assertConnectionStatusIfSupported($clientWithQuery); + if ($response !== null) { + $this->assertContains('project', $response['data']['channels']); + $this->assertArrayHasKey('subscriptions', $response['data']); + $this->assertIsArray($response['data']['subscriptions']); + $this->assertNotEmpty($response['data']['subscriptions']); + } + + $clientWithQuery->close(); + } + + public function testTestsChannelWithQueries() + { + $projectId = 'console'; + + // Subscribe without queries - should receive all events + $clientNoQuery = $this->getWebsocket( + channels: ['tests'], + headers: ['origin' => 'http://localhost'], + projectId: $projectId, + timeout: 5 + ); + + $this->assertConnectionStatusIfSupported($clientNoQuery); + + // Subscribe with matching query - should receive events + $clientWithMatchingQuery = $this->getWebsocket( + channels: ['tests'], + headers: ['origin' => 'http://localhost'], + projectId: $projectId, + queries: [Query::equal('response', ['WS:/v1/realtime:passed'])->toString()], + timeout: 5 + ); + + $this->assertConnectionStatusIfSupported($clientWithMatchingQuery); + + // Subscribe with non-matching query - should NOT receive events + $clientWithNonMatchingQuery = $this->getWebsocket( + channels: ['tests'], + headers: ['origin' => 'http://localhost'], + projectId: $projectId, + queries: [Query::equal('response', ['failed'])->toString()] + ); + + $this->assertConnectionStatusIfSupported($clientWithNonMatchingQuery); + + sleep(2); + + // Client without query should receive event + $eventNoQuery = json_decode($clientNoQuery->receive(), true); + $this->assertEquals('event', $eventNoQuery['type']); + $this->assertEquals('test.event', $eventNoQuery['data']['events'][0]); + $this->assertEquals('WS:/v1/realtime:passed', $eventNoQuery['data']['payload']['response']); + + // Client with matching query should receive event + $eventMatching = json_decode($clientWithMatchingQuery->receive(), true); + $this->assertEquals('event', $eventMatching['type']); + $this->assertEquals('test.event', $eventMatching['data']['events'][0]); + $this->assertEquals('WS:/v1/realtime:passed', $eventMatching['data']['payload']['response']); + + // Client with non-matching query should NOT receive event + try { + $clientWithNonMatchingQuery->receive(); + $this->fail('Expected TimeoutException - client with non-matching query should not receive event'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $clientNoQuery->close(); + $clientWithMatchingQuery->close(); + $clientWithNonMatchingQuery->close(); + } +} From bfbf180aeea78309e88e1d436029e2cb9b76830a Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 2 Apr 2026 18:32:27 +0530 Subject: [PATCH 05/20] Refactor realtime message handling and enhance query validation tests --- app/realtime.php | 22 +++-- .../RealtimeCustomClientQueryTest.php | 94 ++++++++++++++++++ ...altimeCustomClientQueryTestWithMessage.php | 23 +++++ .../Services/Realtime/RealtimeQueryBase.php | 98 +------------------ 4 files changed, 135 insertions(+), 102 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 77b56133ff..a6869e00d9 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -797,16 +797,14 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, } }); -$server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId, $app) { +$server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId) { $project = null; $authorization = null; - + $app = new Http('UTC'); try { $rawSize = \strlen($message); $response = new Response(new SwooleResponse()); $projectId = $realtime->connections[$connection]['projectId'] ?? null; - // TODO: shall it be null or fine to have a guest role? - $roles = $realtime->connections[$connection]['roles'] ?? [Role::guests()->toString()]; // Get authorization from connection (stored during onOpen) $authorization = $realtime->connections[$connection]['authorization'] ?? null; @@ -973,8 +971,18 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.'); } + // TODO: change this to a clean userId fetching solution + $roles = $realtime->connections[$connection]['roles'] ?? [Role::guests()->toString()]; + $userId = ''; + foreach ($roles as $role) { + if (\str_starts_with($role, 'user:')) { + $userId = \substr($role, 5); + break; + } + } + // bulk validation + parsing before subscribing - foreach ($message['data'] as $payload) { + foreach ($message['data'] as &$payload) { if (!array_key_exists('subscriptionId', $payload)) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'subscriptionId is not present in payload.'); } @@ -989,12 +997,12 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re } $subscriptionId = $payload['subscriptionId']; - $channels = $payload['channels']; + $payload['channels'] = \array_keys(Realtime::convertChannels($payload['channels'], $userId)); // TODO: catch error here $payload['queries'] = Query::parseQueries($payload['queries']); } - foreach ($message['data'] as $paylod) { + foreach ($message['data'] as $payload) { $subscriptionId = $payload['subscriptionId']; $channels = $payload['channels']; $queries = $payload['queries']; diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php index c62e2122e2..1ec7de9b92 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php @@ -6,6 +6,7 @@ use Tests\E2E\Scopes\ProjectCustom; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\SideClient; use Tests\E2E\Services\Functions\FunctionsBase; +use Utopia\Database\Query; class RealtimeCustomClientQueryTest extends Scope { @@ -19,4 +20,97 @@ class RealtimeCustomClientQueryTest extends Scope { return true; } + public function testInvalidQueryShouldNotSubscribe() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Test 1: Simple invalid query method (contains is not allowed) + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::contains('status', ['active'])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + $this->assertStringContainsString('contains', $response['data']['message']); + + // Test 2: Invalid query method in nested AND query + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::and([ + Query::equal('status', ['active']), + Query::search('name', 'test') // search is not allowed + ])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + $this->assertStringContainsString('search', $response['data']['message']); + + // Test 3: Invalid query method in nested OR query + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::or([ + Query::equal('status', ['active']), + Query::between('score', 0, 100) // between is not allowed + ])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + $this->assertStringContainsString('between', $response['data']['message']); + + // Test 4: Deeply nested invalid query (AND -> OR -> invalid) + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::and([ + Query::equal('status', ['active']), + Query::or([ + Query::greaterThan('score', 50), + Query::startsWith('name', 'test') // startsWith is not allowed + ]) + ])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + $this->assertStringContainsString('startsWith', $response['data']['message']); + + // Test 5: Multiple invalid 'queries' in nested structure + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::and([ + Query::contains('tags', ['important']), // contains is not allowed + Query::or([ + Query::endsWith('email', '@example.com'), // endsWith is not allowed + Query::equal('status', ['active']) + ]) + ])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + // Should catch the first invalid method encountered + $this->assertTrue( + str_contains($response['data']['message'], 'contains') || + str_contains($response['data']['message'], 'endsWith') + ); + } } diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php index b1de21d455..b053fe3897 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php @@ -24,6 +24,16 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope return false; } + protected function supportForAccountChannelQueryAssertion(): bool + { + return false; + } + + protected function supportForInvalidQueryAssertionOnReceive(): bool + { + return false; + } + /** * Same signature as `RealtimeBase::getWebsocket()`, but: * - never sends queries in the URL (avoids URL length limits) @@ -86,6 +96,19 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope return $client; } + private function getWebsocketWithCustomQuery(array $queryParams, array $headers = [], int $timeout = 2): WebSocketClient + { + $queryString = \http_build_query($queryParams); + + return new WebSocketClient( + 'ws://appwrite.test/v1/realtime?' . $queryString, + [ + 'headers' => $headers, + 'timeout' => $timeout, + ] + ); + } + public function testQueryMessageFiltersEvents(): void { $user = $this->getUser(); diff --git a/tests/e2e/Services/Realtime/RealtimeQueryBase.php b/tests/e2e/Services/Realtime/RealtimeQueryBase.php index c72886b3dc..2ebda2397f 100644 --- a/tests/e2e/Services/Realtime/RealtimeQueryBase.php +++ b/tests/e2e/Services/Realtime/RealtimeQueryBase.php @@ -1719,100 +1719,6 @@ trait RealtimeQueryBase $client->close(); } - public function testInvalidQueryShouldNotSubscribe() - { - $user = $this->getUser(); - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Test 1: Simple invalid query method (contains is not allowed) - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::contains('status', ['active'])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('error', $response['type']); - $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); - $this->assertStringContainsString('contains', $response['data']['message']); - - // Test 2: Invalid query method in nested AND query - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::and([ - Query::equal('status', ['active']), - Query::search('name', 'test') // search is not allowed - ])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('error', $response['type']); - $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); - $this->assertStringContainsString('search', $response['data']['message']); - - // Test 3: Invalid query method in nested OR query - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::or([ - Query::equal('status', ['active']), - Query::between('score', 0, 100) // between is not allowed - ])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('error', $response['type']); - $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); - $this->assertStringContainsString('between', $response['data']['message']); - - // Test 4: Deeply nested invalid query (AND -> OR -> invalid) - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::and([ - Query::equal('status', ['active']), - Query::or([ - Query::greaterThan('score', 50), - Query::startsWith('name', 'test') // startsWith is not allowed - ]) - ])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('error', $response['type']); - $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); - $this->assertStringContainsString('startsWith', $response['data']['message']); - - // Test 5: Multiple invalid 'queries' in nested structure - $client = $this->getWebsocket(['documents'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::and([ - Query::contains('tags', ['important']), // contains is not allowed - Query::or([ - Query::endsWith('email', '@example.com'), // endsWith is not allowed - Query::equal('status', ['active']) - ]) - ])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('error', $response['type']); - $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); - // Should catch the first invalid method encountered - $this->assertTrue( - str_contains($response['data']['message'], 'contains') || - str_contains($response['data']['message'], 'endsWith') - ); - } - public function testQueryKeys() { $user = $this->getUser(); @@ -2398,7 +2304,9 @@ trait RealtimeQueryBase $this->assertIsArray($event2['data']['subscriptions']); $this->assertNotEmpty($event2['data']['subscriptions']); // Subscription ID should remain stable after permission change - $this->assertContains($originalSubscriptionId, $event2['data']['subscriptions']); + if ($originalSubscriptionId !== null) { + $this->assertContains($originalSubscriptionId, $event2['data']['subscriptions']); + } $client->close(); } From 187fde4a4edc740a44a70ea0747f1d858ae65bc1 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 6 Apr 2026 14:05:42 +0530 Subject: [PATCH 06/20] Refactor realtime subscription handling and enhance query validation in tests --- app/realtime.php | 39 ++-- .../RealtimeCustomClientQueryTest.php | 59 ++++++ ...altimeCustomClientQueryTestWithMessage.php | 176 +++++++++++++++++- .../Services/Realtime/RealtimeQueryBase.php | 59 ------ 4 files changed, 259 insertions(+), 74 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index a6869e00d9..51ee374e84 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -800,7 +800,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId) { $project = null; $authorization = null; - $app = new Http('UTC'); try { $rawSize = \strlen($message); $response = new Response(new SwooleResponse()); @@ -960,9 +959,10 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re break; - case 'query': + case 'subscribe': // TODO: record stats /** + * Message based subscription * to update a query of an existing subscription for channels * structure of the payload -> array of maps * 'data' : [subscriptionId:"" , channels:[] , queries:[]] @@ -983,9 +983,6 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re // bulk validation + parsing before subscribing foreach ($message['data'] as &$payload) { - if (!array_key_exists('subscriptionId', $payload)) { - throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'subscriptionId is not present in payload.'); - } if (!array_key_exists('channels', $payload)) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'channels is not present in payload.'); } @@ -995,33 +992,49 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re if (!array_key_exists('queries', $payload)) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'queries is not present in payload.'); } + if (!array_key_exists('subscriptionId', $payload)) { + $payload['subscriptionId'] = ID::unique(); + } - $subscriptionId = $payload['subscriptionId']; - $payload['channels'] = \array_keys(Realtime::convertChannels($payload['channels'], $userId)); - // TODO: catch error here - $payload['queries'] = Query::parseQueries($payload['queries']); + if (!array_key_exists('queries', $payload)) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'queries is not present in payload.'); + } + if (!is_array($payload['queries']) || !array_is_list($payload['queries'])) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'queries is not a valid array.'); + } + + try { + $payload['queries'] = Realtime::convertQueries($payload['queries']); + } catch (QueryException $e) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Invalid query: ' . $e->getMessage()); + } } + unset($payload); foreach ($message['data'] as $payload) { $subscriptionId = $payload['subscriptionId']; - $channels = $payload['channels']; + $channels = \array_keys(Realtime::convertChannels($payload['channels'], $userId)); $queries = $payload['queries']; $realtime->subscribe($projectId, $connection, $subscriptionId, $roles, $channels, $queries); } + // TODO: find a better way to store the queries and no reconversion $responsePayload = json_encode([ 'type' => 'response', 'data' => [ - 'to' => 'query', + 'to' => 'subscribe', 'success' => true, - 'subscriptions' => $message['data'] + 'subscriptions' => array_map(function ($payload) { + return array_merge($payload, [ + 'queries' => array_map(fn ($q) => $q->toString(), $payload['queries']), + ]); + }, $message['data']) ] ]); $server->send([$connection], $responsePayload); break; - default: throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.'); } diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php index 1ec7de9b92..ff5e981de2 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php @@ -113,4 +113,63 @@ class RealtimeCustomClientQueryTest extends Scope str_contains($response['data']['message'], 'endsWith') ); } + + public function testProjectChannelWithHeaderOnly() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Test: project ID only in header, no project query param + // This simulates a client that only uses x-appwrite-project header + $client = $this->getWebsocketWithCustomQuery( + [ + 'channels' => ['project'] + ], + [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + 'x-appwrite-project' => $projectId, + ] + ); + + $response = $this->assertConnectionStatusIfSupported($client); + if ($response !== null) { + $this->assertContains('project', $response['data']['channels']); + // Should have default select(['*']) subscription since no project query param + $this->assertArrayHasKey('subscriptions', $response['data']); + $this->assertIsArray($response['data']['subscriptions']); + $this->assertNotEmpty($response['data']['subscriptions']); + } + + $client->close(); + + // Test: project channel with queries, project ID only in header + $queryArray = [Query::select(['*'])->toString()]; + $clientWithQuery = $this->getWebsocketWithCustomQuery( + [ + 'channels' => ['project'], + 'project' => [ + 0 => [ + 0 => $queryArray[0] + ] + ] + ], + [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + 'x-appwrite-project' => $projectId, + ] + ); + + $response = $this->assertConnectionStatusIfSupported($clientWithQuery); + if ($response !== null) { + $this->assertContains('project', $response['data']['channels']); + $this->assertArrayHasKey('subscriptions', $response['data']); + $this->assertIsArray($response['data']['subscriptions']); + $this->assertNotEmpty($response['data']['subscriptions']); + } + + $clientWithQuery->close(); + } } diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php index b053fe3897..3f87da599a 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php @@ -78,7 +78,7 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope } $client->send(\json_encode([ - 'type' => 'query', + 'type' => 'subscribe', 'data' => [[ 'subscriptionId' => $subscriptionId, 'channels' => $channels, @@ -88,7 +88,7 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope $response = \json_decode($client->receive(), true); $this->assertEquals('response', $response['type'] ?? null); - $this->assertEquals('query', $response['data']['to'] ?? null); + $this->assertEquals('subscribe', $response['data']['to'] ?? null); $this->assertTrue($response['data']['success'] ?? false); $this->assertArrayHasKey('subscriptions', $response['data']); $this->assertIsArray($response['data']['subscriptions']); @@ -96,6 +96,53 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope return $client; } + /** + * Connects (URL has no per-channel queries), then sends a subscribe message with the given query strings. + * Used to assert server rejects unsupported query methods the same way as URL-based subscriptions. + * + * @param array $queryStrings + * @return array + */ + private function receiveSubscribeMessageResponse( + array $channels, + array $headers, + array $queryStrings + ): array { + $projectId = $this->getProject()['$id']; + $queryString = \http_build_query([ + 'project' => $projectId, + 'channels' => $channels, + ]); + + $client = new WebSocketClient( + 'ws://appwrite.test/v1/realtime?' . $queryString, + [ + 'headers' => $headers, + 'timeout' => 2, + ] + ); + $connected = \json_decode($client->receive(), true); + $this->assertEquals('connected', $connected['type'] ?? null); + + $subscriptions = $connected['data']['subscriptions'] ?? []; + $this->assertNotEmpty($subscriptions); + $subscriptionId = $subscriptions[\array_key_first($subscriptions)]; + + $client->send(\json_encode([ + 'type' => 'subscribe', + 'data' => [[ + 'subscriptionId' => $subscriptionId, + 'channels' => $channels, + 'queries' => $queryStrings, + ]], + ])); + + $response = \json_decode($client->receive(), true); + $client->close(); + + return $response; + } + private function getWebsocketWithCustomQuery(array $queryParams, array $headers = [], int $timeout = 2): WebSocketClient { $queryString = \http_build_query($queryParams); @@ -109,6 +156,131 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope ); } + public function testInvalidQueryShouldNotSubscribe(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + // Test 1: Simple invalid query method (contains is not allowed) + $response = $this->receiveSubscribeMessageResponse(['documents'], $headers, [ + Query::contains('status', ['active'])->toString(), + ]); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + $this->assertStringContainsString('contains', $response['data']['message']); + + // Test 2: Invalid query method in nested AND query + $response = $this->receiveSubscribeMessageResponse(['documents'], $headers, [ + Query::and([ + Query::equal('status', ['active']), + Query::search('name', 'test'), + ])->toString(), + ]); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + $this->assertStringContainsString('search', $response['data']['message']); + + // Test 3: Invalid query method in nested OR query + $response = $this->receiveSubscribeMessageResponse(['documents'], $headers, [ + Query::or([ + Query::equal('status', ['active']), + Query::between('score', 0, 100), + ])->toString(), + ]); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + $this->assertStringContainsString('between', $response['data']['message']); + + // Test 4: Deeply nested invalid query (AND -> OR -> invalid) + $response = $this->receiveSubscribeMessageResponse(['documents'], $headers, [ + Query::and([ + Query::equal('status', ['active']), + Query::or([ + Query::greaterThan('score', 50), + Query::startsWith('name', 'test'), + ]), + ])->toString(), + ]); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + $this->assertStringContainsString('startsWith', $response['data']['message']); + + // Test 5: Multiple invalid 'queries' in nested structure + $response = $this->receiveSubscribeMessageResponse(['documents'], $headers, [ + Query::and([ + Query::contains('tags', ['important']), + Query::or([ + Query::endsWith('email', '@example.com'), + Query::equal('status', ['active']), + ]), + ])->toString(), + ]); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + $this->assertTrue( + \str_contains($response['data']['message'], 'contains') || + \str_contains($response['data']['message'], 'endsWith') + ); + } + + public function testProjectChannelWithHeaderOnly(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $client = $this->getWebsocketWithCustomQuery( + [ + 'channels' => ['project'], + ], + [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + 'x-appwrite-project' => $projectId, + ] + ); + + $response = \json_decode($client->receive(), true); + $this->assertSame('connected', $response['type']); + $this->assertContains('project', $response['data']['channels']); + $this->assertArrayHasKey('subscriptions', $response['data']); + $this->assertIsArray($response['data']['subscriptions']); + $this->assertNotEmpty($response['data']['subscriptions']); + + $client->close(); + + $queryArray = [Query::select(['*'])->toString()]; + $clientWithQuery = $this->getWebsocketWithCustomQuery( + [ + 'channels' => ['project'], + 'project' => [ + 0 => [ + 0 => $queryArray[0], + ], + ], + ], + [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + 'x-appwrite-project' => $projectId, + ] + ); + + $response = \json_decode($clientWithQuery->receive(), true); + $this->assertSame('connected', $response['type']); + $this->assertContains('project', $response['data']['channels']); + $this->assertArrayHasKey('subscriptions', $response['data']); + $this->assertIsArray($response['data']['subscriptions']); + $this->assertNotEmpty($response['data']['subscriptions']); + + $clientWithQuery->close(); + } + public function testQueryMessageFiltersEvents(): void { $user = $this->getUser(); diff --git a/tests/e2e/Services/Realtime/RealtimeQueryBase.php b/tests/e2e/Services/Realtime/RealtimeQueryBase.php index 2ebda2397f..cb74e25cbd 100644 --- a/tests/e2e/Services/Realtime/RealtimeQueryBase.php +++ b/tests/e2e/Services/Realtime/RealtimeQueryBase.php @@ -2385,65 +2385,6 @@ trait RealtimeQueryBase $this->assertStringContainsString('Invalid query', $response['data']['message']); } - public function testProjectChannelWithHeaderOnly() - { - $user = $this->getUser(); - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Test: project ID only in header, no project query param - // This simulates a client that only uses x-appwrite-project header - $client = $this->getWebsocketWithCustomQuery( - [ - 'channels' => ['project'] - ], - [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - 'x-appwrite-project' => $projectId, - ] - ); - - $response = $this->assertConnectionStatusIfSupported($client); - if ($response !== null) { - $this->assertContains('project', $response['data']['channels']); - // Should have default select(['*']) subscription since no project query param - $this->assertArrayHasKey('subscriptions', $response['data']); - $this->assertIsArray($response['data']['subscriptions']); - $this->assertNotEmpty($response['data']['subscriptions']); - } - - $client->close(); - - // Test: project channel with queries, project ID only in header - $queryArray = [Query::select(['*'])->toString()]; - $clientWithQuery = $this->getWebsocketWithCustomQuery( - [ - 'channels' => ['project'], - 'project' => [ - 0 => [ - 0 => $queryArray[0] - ] - ] - ], - [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - 'x-appwrite-project' => $projectId, - ] - ); - - $response = $this->assertConnectionStatusIfSupported($clientWithQuery); - if ($response !== null) { - $this->assertContains('project', $response['data']['channels']); - $this->assertArrayHasKey('subscriptions', $response['data']); - $this->assertIsArray($response['data']['subscriptions']); - $this->assertNotEmpty($response['data']['subscriptions']); - } - - $clientWithQuery->close(); - } - public function testTestsChannelWithQueries() { $projectId = 'console'; From 592629587dc6f10da990814cc9dde1d5a3c29123 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 6 Apr 2026 14:07:25 +0530 Subject: [PATCH 07/20] Remove unused query assertion methods and improve comment clarity in RealtimeQueryBase --- .../RealtimeCustomClientQueryTestWithMessage.php | 10 ---------- tests/e2e/Services/Realtime/RealtimeQueryBase.php | 2 +- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php index 3f87da599a..0303db08ef 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php @@ -24,16 +24,6 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope return false; } - protected function supportForAccountChannelQueryAssertion(): bool - { - return false; - } - - protected function supportForInvalidQueryAssertionOnReceive(): bool - { - return false; - } - /** * Same signature as `RealtimeBase::getWebsocket()`, but: * - never sends queries in the URL (avoids URL length limits) diff --git a/tests/e2e/Services/Realtime/RealtimeQueryBase.php b/tests/e2e/Services/Realtime/RealtimeQueryBase.php index cb74e25cbd..04ed56dae6 100644 --- a/tests/e2e/Services/Realtime/RealtimeQueryBase.php +++ b/tests/e2e/Services/Realtime/RealtimeQueryBase.php @@ -1673,7 +1673,7 @@ trait RealtimeQueryBase // Create document matching NEITHER query - should not receive event // keeping it here as below are the documents created with status=>active - // so it will also receive it but the querykey can be used to distinction + // so it will also be received, but the query key can be used to distinguish it $anotherDocId = ID::unique(); $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ 'content-type' => 'application/json', From 0f47e6ea28272e444fe8c26b66ca92187f4d94eb Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 6 Apr 2026 15:59:22 +0530 Subject: [PATCH 08/20] Enhance subscription message documentation for clarity on upsertion behavior --- app/realtime.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 51ee374e84..2068ae7704 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -962,8 +962,11 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re case 'subscribe': // TODO: record stats /** - * Message based subscription - * to update a query of an existing subscription for channels + * Message based upsertion of a subscription + * If subscriptionId is given then it will match subId of the connection and update the subscription with channels and queries + * If non-existing subid is given or not given a new subid will be generated + * Similar to what we have now -> two subscribe() block with same channels and queries still two different subscriptions + * * structure of the payload -> array of maps * 'data' : [subscriptionId:"" , channels:[] , queries:[]] */ From d12a6f51680dcab779723044d55c265e58d9b40a Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 6 Apr 2026 16:41:42 +0530 Subject: [PATCH 09/20] Refactor realtime message payload handling for improved validation and parsing --- app/realtime.php | 46 ++++++++++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 2068ae7704..97d3d84648 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -985,20 +985,14 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re } // bulk validation + parsing before subscribing - foreach ($message['data'] as &$payload) { + $parsedPayloads = []; + foreach ($message['data'] as $payload) { if (!array_key_exists('channels', $payload)) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'channels is not present in payload.'); } if (!is_array($payload['channels']) || !array_is_list($payload['channels'])) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'channels is not a valid array.'); } - if (!array_key_exists('queries', $payload)) { - throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'queries is not present in payload.'); - } - if (!array_key_exists('subscriptionId', $payload)) { - $payload['subscriptionId'] = ID::unique(); - } - if (!array_key_exists('queries', $payload)) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'queries is not present in payload.'); } @@ -1006,18 +1000,28 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'queries is not a valid array.'); } + $subscriptionId = \array_key_exists('subscriptionId', $payload) + ? $payload['subscriptionId'] + : ID::unique(); + try { - $payload['queries'] = Realtime::convertQueries($payload['queries']); + $convertedQueries = Realtime::convertQueries($payload['queries']); } catch (QueryException $e) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Invalid query: ' . $e->getMessage()); } - } - unset($payload); - foreach ($message['data'] as $payload) { - $subscriptionId = $payload['subscriptionId']; - $channels = \array_keys(Realtime::convertChannels($payload['channels'], $userId)); - $queries = $payload['queries']; + $parsedPayloads[] = [ + 'subscriptionId' => $subscriptionId, + 'channels' => $payload['channels'], + 'queries' => $convertedQueries, + ]; + } + + foreach ($parsedPayloads as $parsedPayload) { + $subscriptionId = $parsedPayload['subscriptionId']; + $channels = \array_keys(Realtime::convertChannels($parsedPayload['channels'], $userId)); + $queries = $parsedPayload['queries']; + $realtime->removeSubscriptionForConnection($projectId, $connection, $subscriptionId); $realtime->subscribe($projectId, $connection, $subscriptionId, $roles, $channels, $queries); } @@ -1027,11 +1031,13 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re 'data' => [ 'to' => 'subscribe', 'success' => true, - 'subscriptions' => array_map(function ($payload) { - return array_merge($payload, [ - 'queries' => array_map(fn ($q) => $q->toString(), $payload['queries']), - ]); - }, $message['data']) + 'subscriptions' => \array_map(function (array $parsedPayload) { + return [ + 'subscriptionId' => $parsedPayload['subscriptionId'], + 'channels' => $parsedPayload['channels'], + 'queries' => \array_map(fn ($q) => $q->toString(), $parsedPayload['queries']), + ]; + }, $parsedPayloads), ] ]); From 9d78a8e6b6f352c40d6e410f014a599dcfb8cc6e Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 6 Apr 2026 16:57:06 +0530 Subject: [PATCH 10/20] Add stats tracking for outbound subscription messages in realtime --- app/realtime.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/realtime.php b/app/realtime.php index 97d3d84648..7e8e35e933 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -960,7 +960,6 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re break; case 'subscribe': - // TODO: record stats /** * Message based upsertion of a subscription * If subscriptionId is given then it will match subId of the connection and update the subscription with channels and queries @@ -1042,6 +1041,17 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ]); $server->send([$connection], $responsePayload); + + if ($project !== null && !$project->isEmpty()) { + $subscribeOutboundBytes = \strlen($responsePayload); + + if ($subscribeOutboundBytes > 0) { + triggerStats([ + METRIC_REALTIME_OUTBOUND => $subscribeOutboundBytes, + ], $project->getId()); + } + } + break; default: From 97d46c6273e93c1d77c79daa77fda854e8c8dcae Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 6 Apr 2026 16:58:08 +0530 Subject: [PATCH 11/20] Remove redundant subscription removal call in realtime message handling --- app/realtime.php | 1 - 1 file changed, 1 deletion(-) diff --git a/app/realtime.php b/app/realtime.php index 7e8e35e933..41c4be6637 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -1020,7 +1020,6 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $subscriptionId = $parsedPayload['subscriptionId']; $channels = \array_keys(Realtime::convertChannels($parsedPayload['channels'], $userId)); $queries = $parsedPayload['queries']; - $realtime->removeSubscriptionForConnection($projectId, $connection, $subscriptionId); $realtime->subscribe($projectId, $connection, $subscriptionId, $roles, $channels, $queries); } From 6bc9adece8f25fc9695eee9e2131d6acea50eaa7 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 6 Apr 2026 17:10:57 +0530 Subject: [PATCH 12/20] Refactor realtime message handling to send subscriber keys and add comprehensive tests for subscription message upsert behavior --- app/realtime.php | 2 +- ...altimeCustomClientQueryTestWithMessage.php | 147 ++++++++++++++++++ 2 files changed, 148 insertions(+), 1 deletion(-) diff --git a/app/realtime.php b/app/realtime.php index 41c4be6637..d7542c0457 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -441,7 +441,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, ] ]; - $server->send($realtime->getSubscribers($event), json_encode([ + $server->send(array_keys($realtime->getSubscribers($event)), json_encode([ 'type' => 'event', 'data' => $event['data'] ])); diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php index 0303db08ef..0185eb873f 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php @@ -146,6 +146,153 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope ); } + /** + * @param array> $payloadEntries + * @return array + */ + private function sendSubscribeMessage(WebSocketClient $client, array $payloadEntries): array + { + $client->send(\json_encode([ + 'type' => 'subscribe', + 'data' => $payloadEntries, + ])); + $response = \json_decode($client->receive(), true); + $this->assertEquals('response', $response['type'] ?? null); + $this->assertEquals('subscribe', $response['data']['to'] ?? null); + $this->assertTrue($response['data']['success'] ?? false); + $this->assertArrayHasKey('subscriptions', $response['data']); + $this->assertIsArray($response['data']['subscriptions']); + + return $response; + } + + /** + * subscriptionId: update with id from connected, create by omitting id, explicit new id, + * duplicate id in one bulk (last wins), mixed bulk, idempotent repeat, empty queries → select-all. + */ + public function testSubscribeMessageUpsertCreateAndEdgeCases(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + $queryString = \http_build_query([ + 'project' => $projectId, + 'channels' => ['documents'], + ]); + $client = new WebSocketClient( + 'ws://appwrite.test/v1/realtime?' . $queryString, + [ + 'headers' => $headers, + 'timeout' => 30, + ] + ); + $connected = \json_decode($client->receive(), true); + $this->assertEquals('connected', $connected['type'] ?? null); + $mapping = $connected['data']['subscriptions'] ?? []; + $this->assertNotEmpty($mapping); + $initialSubscriptionId = $mapping[\array_key_first($mapping)]; + + $q1 = [Query::equal('status', ['q1'])->toString()]; + $r1 = $this->sendSubscribeMessage($client, [[ + 'subscriptionId' => $initialSubscriptionId, + 'channels' => ['documents'], + 'queries' => $q1, + ]]); + $this->assertCount(1, $r1['data']['subscriptions']); + $this->assertSame($initialSubscriptionId, $r1['data']['subscriptions'][0]['subscriptionId']); + $this->assertSame($q1, $r1['data']['subscriptions'][0]['queries']); + + $q2 = [Query::equal('status', ['q2'])->toString()]; + $r2 = $this->sendSubscribeMessage($client, [[ + 'subscriptionId' => $initialSubscriptionId, + 'channels' => ['documents'], + 'queries' => $q2, + ]]); + $this->assertSame($initialSubscriptionId, $r2['data']['subscriptions'][0]['subscriptionId']); + $this->assertSame($q2, $r2['data']['subscriptions'][0]['queries']); + + $rOmit = $this->sendSubscribeMessage($client, [[ + 'channels' => ['documents'], + 'queries' => [Query::equal('status', ['omitted-slot'])->toString()], + ]]); + $mintedId = $rOmit['data']['subscriptions'][0]['subscriptionId']; + $this->assertNotSame($initialSubscriptionId, $mintedId); + $this->assertNotEmpty($mintedId); + + $explicitNewId = ID::unique(); + $qExplicit = [Query::equal('status', ['explicit'])->toString()]; + $rExplicit = $this->sendSubscribeMessage($client, [[ + 'subscriptionId' => $explicitNewId, + 'channels' => ['documents'], + 'queries' => $qExplicit, + ]]); + $this->assertSame($explicitNewId, $rExplicit['data']['subscriptions'][0]['subscriptionId']); + $this->assertSame($qExplicit, $rExplicit['data']['subscriptions'][0]['queries']); + + $qFirst = [Query::equal('status', ['dup-a'])->toString()]; + $qSecond = [Query::equal('status', ['dup-b'])->toString()]; + $rDup = $this->sendSubscribeMessage($client, [ + [ + 'subscriptionId' => $initialSubscriptionId, + 'channels' => ['documents'], + 'queries' => $qFirst, + ], + [ + 'subscriptionId' => $initialSubscriptionId, + 'channels' => ['documents'], + 'queries' => $qSecond, + ], + ]); + $this->assertCount(2, $rDup['data']['subscriptions']); + $this->assertSame($initialSubscriptionId, $rDup['data']['subscriptions'][0]['subscriptionId']); + $this->assertSame($initialSubscriptionId, $rDup['data']['subscriptions'][1]['subscriptionId']); + $this->assertSame($qSecond, $rDup['data']['subscriptions'][1]['queries']); + + $rMixed = $this->sendSubscribeMessage($client, [ + [ + 'subscriptionId' => $initialSubscriptionId, + 'channels' => ['documents'], + 'queries' => [Query::equal('status', ['mixed-update'])->toString()], + ], + [ + 'channels' => ['documents'], + 'queries' => [Query::equal('status', ['mixed-new'])->toString()], + ], + ]); + $this->assertCount(2, $rMixed['data']['subscriptions']); + $this->assertSame($initialSubscriptionId, $rMixed['data']['subscriptions'][0]['subscriptionId']); + $mixedSecondId = $rMixed['data']['subscriptions'][1]['subscriptionId']; + $this->assertNotSame($initialSubscriptionId, $mixedSecondId); + $this->assertNotEmpty($mixedSecondId); + + $rSame = $this->sendSubscribeMessage($client, [[ + 'subscriptionId' => $initialSubscriptionId, + 'channels' => ['documents'], + 'queries' => [Query::equal('status', ['idempotent'])->toString()], + ]]); + $rSameAgain = $this->sendSubscribeMessage($client, [[ + 'subscriptionId' => $initialSubscriptionId, + 'channels' => ['documents'], + 'queries' => [Query::equal('status', ['idempotent'])->toString()], + ]]); + $this->assertSame($rSame['data']['subscriptions'][0]['queries'], $rSameAgain['data']['subscriptions'][0]['queries']); + + $rEmpty = $this->sendSubscribeMessage($client, [[ + 'subscriptionId' => $initialSubscriptionId, + 'channels' => ['documents'], + 'queries' => [], + ]]); + $this->assertCount(1, $rEmpty['data']['subscriptions']); + $this->assertSame($initialSubscriptionId, $rEmpty['data']['subscriptions'][0]['subscriptionId']); + + $client->close(); + } + public function testInvalidQueryShouldNotSubscribe(): void { $user = $this->getUser(); From d5fe5c34af37e3cd5ff435467d09e8fcd0cd9d8a Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 6 Apr 2026 17:14:12 +0530 Subject: [PATCH 13/20] Validate subscribe payload format in realtime message handling --- app/realtime.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/realtime.php b/app/realtime.php index d7542c0457..796686be3e 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -986,6 +986,9 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re // bulk validation + parsing before subscribing $parsedPayloads = []; foreach ($message['data'] as $payload) { + if (!\is_array($payload)) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Each subscribe payload must be an object.'); + } if (!array_key_exists('channels', $payload)) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'channels is not present in payload.'); } From ca62504b5acfebf2ce97c7331824830a7bff2ddb Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 7 Apr 2026 16:55:10 +0530 Subject: [PATCH 14/20] Enhance realtime message handling to support initial connection payload and improve query subscription logic --- app/realtime.php | 17 ++++- src/Appwrite/Messaging/Adapter/Realtime.php | 25 ++++--- ...altimeCustomClientQueryTestWithMessage.php | 74 +++++++++---------- 3 files changed, 65 insertions(+), 51 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 796686be3e..3bceb54f26 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -701,7 +701,19 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, * Channels Check */ if (empty($channels)) { - throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing channels'); + // in case of message based 'subscribe' channels will be empty at first and only projectId and roles will be available + $connectedPayloadJson = json_encode([ + 'type' => 'connected', + 'data' => [ + 'channels' => [], + 'subscriptions' => [], + 'user' => $user + ] + ]); + + $realtime->subscribe($project->getId(), $connection, '', $roles, [], []); + $server->send([$connection], $connectedPayloadJson); + return; } $names = array_keys($channels); @@ -995,8 +1007,9 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re if (!is_array($payload['channels']) || !array_is_list($payload['channels'])) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'channels is not a valid array.'); } + // registering the queries if not present and check in the same payload later on if (!array_key_exists('queries', $payload)) { - throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'queries is not present in payload.'); + $payload['queries'] = []; } if (!is_array($payload['queries']) || !array_is_list($payload['queries'])) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'queries is not a valid array.'); diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 7a2b6fe19a..f9e97f0e45 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -74,18 +74,21 @@ class Realtime extends MessagingAdapter } $strings = []; - if (empty($queryGroup)) { - $strings[] = Query::select(['*'])->toString(); - } else { - foreach ($queryGroup as $query) { - $strings[] = $query->toString(); - } - } + $data = []; - $data = [ - 'strings' => $strings, - 'compiled' => RuntimeQuery::compile($queryGroup), - ]; + if (!empty($channels)) { + if (empty($queryGroup)) { + $strings[] = Query::select(['*'])->toString(); + } else { + foreach ($queryGroup as $query) { + $strings[] = $query->toString(); + } + } + $data = [ + 'strings' => $strings, + 'compiled' => RuntimeQuery::compile($queryGroup), + ]; + } foreach ($roles as $role) { if (!isset($this->subscriptions[$projectId][$role])) { diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php index 0185eb873f..edce428e0f 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php @@ -27,7 +27,7 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope /** * Same signature as `RealtimeBase::getWebsocket()`, but: * - never sends queries in the URL (avoids URL length limits) - * - once connected, updates the generated subscription using a bulk `type: "query"` message + * - once connected, sends channel/query data using a `type: "subscribe"` message */ private function getWebsocket( array $channels = [], @@ -42,7 +42,6 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope $queryString = \http_build_query([ 'project' => $projectId, - 'channels' => $channels, ]); $client = new WebSocketClient( @@ -55,25 +54,30 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope $connected = \json_decode($client->receive(), true); $this->assertEquals('connected', $connected['type'] ?? null); - if ($queries === null) { + if (empty($channels)) { return $client; } - $subscriptions = $connected['data']['subscriptions'] ?? []; - $this->assertNotEmpty($subscriptions); - $subscriptionId = $subscriptions[\array_key_first($subscriptions)]; - if ($queries === []) { $queries = [Query::select(['*'])->toString()]; } + $payload = [[ + 'channels' => $channels, + ]]; + + if ($queries !== null) { + $payload[0]['queries'] = $queries; + } + + $existingSubscriptions = $connected['data']['subscriptions'] ?? []; + if (!empty($existingSubscriptions)) { + $payload[0]['subscriptionId'] = $existingSubscriptions[\array_key_first($existingSubscriptions)]; + } + $client->send(\json_encode([ 'type' => 'subscribe', - 'data' => [[ - 'subscriptionId' => $subscriptionId, - 'channels' => $channels, - 'queries' => $queries, - ]], + 'data' => $payload, ])); $response = \json_decode($client->receive(), true); @@ -101,7 +105,6 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope $projectId = $this->getProject()['$id']; $queryString = \http_build_query([ 'project' => $projectId, - 'channels' => $channels, ]); $client = new WebSocketClient( @@ -114,14 +117,9 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope $connected = \json_decode($client->receive(), true); $this->assertEquals('connected', $connected['type'] ?? null); - $subscriptions = $connected['data']['subscriptions'] ?? []; - $this->assertNotEmpty($subscriptions); - $subscriptionId = $subscriptions[\array_key_first($subscriptions)]; - $client->send(\json_encode([ 'type' => 'subscribe', 'data' => [[ - 'subscriptionId' => $subscriptionId, 'channels' => $channels, 'queries' => $queryStrings, ]], @@ -182,7 +180,6 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope $queryString = \http_build_query([ 'project' => $projectId, - 'channels' => ['documents'], ]); $client = new WebSocketClient( 'ws://appwrite.test/v1/realtime?' . $queryString, @@ -193,9 +190,12 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope ); $connected = \json_decode($client->receive(), true); $this->assertEquals('connected', $connected['type'] ?? null); - $mapping = $connected['data']['subscriptions'] ?? []; - $this->assertNotEmpty($mapping); - $initialSubscriptionId = $mapping[\array_key_first($mapping)]; + $initialResponse = $this->sendSubscribeMessage($client, [[ + 'channels' => ['documents'], + 'queries' => [Query::select(['*'])->toString()], + ]]); + $initialSubscriptionId = $initialResponse['data']['subscriptions'][0]['subscriptionId'] ?? ''; + $this->assertNotEmpty($initialSubscriptionId); $q1 = [Query::equal('status', ['q1'])->toString()]; $r1 = $this->sendSubscribeMessage($client, [[ @@ -373,7 +373,7 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope $client = $this->getWebsocketWithCustomQuery( [ - 'channels' => ['project'], + 'project' => $projectId, ], [ 'origin' => 'http://localhost', @@ -384,22 +384,18 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope $response = \json_decode($client->receive(), true); $this->assertSame('connected', $response['type']); - $this->assertContains('project', $response['data']['channels']); - $this->assertArrayHasKey('subscriptions', $response['data']); - $this->assertIsArray($response['data']['subscriptions']); - $this->assertNotEmpty($response['data']['subscriptions']); + $subscribeResponse = $this->sendSubscribeMessage($client, [[ + 'channels' => ['project'], + 'queries' => [Query::select(['*'])->toString()], + ]]); + $this->assertCount(1, $subscribeResponse['data']['subscriptions']); + $this->assertSame(['project'], $subscribeResponse['data']['subscriptions'][0]['channels']); $client->close(); - $queryArray = [Query::select(['*'])->toString()]; $clientWithQuery = $this->getWebsocketWithCustomQuery( [ - 'channels' => ['project'], - 'project' => [ - 0 => [ - 0 => $queryArray[0], - ], - ], + 'project' => $projectId, ], [ 'origin' => 'http://localhost', @@ -410,10 +406,12 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope $response = \json_decode($clientWithQuery->receive(), true); $this->assertSame('connected', $response['type']); - $this->assertContains('project', $response['data']['channels']); - $this->assertArrayHasKey('subscriptions', $response['data']); - $this->assertIsArray($response['data']['subscriptions']); - $this->assertNotEmpty($response['data']['subscriptions']); + $subscribeResponseWithQuery = $this->sendSubscribeMessage($clientWithQuery, [[ + 'channels' => ['project'], + 'queries' => [Query::select(['*'])->toString()], + ]]); + $this->assertCount(1, $subscribeResponseWithQuery['data']['subscriptions']); + $this->assertSame(['project'], $subscribeResponseWithQuery['data']['subscriptions'][0]['channels']); $clientWithQuery->close(); } From bc224de75137796d5d0ed8cc36f4bc904e33335a Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 7 Apr 2026 17:35:48 +0530 Subject: [PATCH 15/20] Add userId to connection info in Realtime adapter and simplify userId fetching --- app/realtime.php | 18 ++++++------------ src/Appwrite/Messaging/Adapter/Realtime.php | 16 +++++++++++++--- tests/e2e/Services/Realtime/RealtimeBase.php | 8 ++------ 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 3bceb54f26..52bf62a11f 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -711,7 +711,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, ] ]); - $realtime->subscribe($project->getId(), $connection, '', $roles, [], []); + $realtime->subscribe($project->getId(), $connection, '', $roles, [], [], $user->getId()); $server->send([$connection], $connectedPayloadJson); return; } @@ -737,7 +737,8 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $subscriptionId, $roles, $subscription['channels'], - $subscription['queries'] + $subscription['queries'], + $user->getId() ); $mapping[$index] = $subscriptionId; @@ -937,7 +938,8 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $subscriptionId, $roles, $subscription['channels'] ?? [], - $queries + $queries, + $user->getId() ); } } @@ -985,15 +987,8 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.'); } - // TODO: change this to a clean userId fetching solution $roles = $realtime->connections[$connection]['roles'] ?? [Role::guests()->toString()]; - $userId = ''; - foreach ($roles as $role) { - if (\str_starts_with($role, 'user:')) { - $userId = \substr($role, 5); - break; - } - } + $userId = $realtime->connections[$connection]['userId'] ?? ''; // bulk validation + parsing before subscribing $parsedPayloads = []; @@ -1039,7 +1034,6 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $realtime->subscribe($projectId, $connection, $subscriptionId, $roles, $channels, $queries); } - // TODO: find a better way to store the queries and no reconversion $responsePayload = json_encode([ 'type' => 'response', 'data' => [ diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index f9e97f0e45..f1d806bcc5 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -20,6 +20,7 @@ class Realtime extends MessagingAdapter * [CONNECTION_ID] -> * 'projectId' -> [PROJECT_ID] * 'roles' -> [ROLE_x, ROLE_Y] + * 'userId' -> [USER_ID] * 'channels' -> [CHANNEL_NAME_X, CHANNEL_NAME_Y, CHANNEL_NAME_Z] */ public array $connections = []; @@ -67,8 +68,15 @@ class Realtime extends MessagingAdapter * @param array $queryGroup Array of Query objects for this subscription (AND logic within subscription) * @return void */ - public function subscribe(string $projectId, mixed $identifier, string $subscriptionId, array $roles, array $channels, array $queryGroup = []): void - { + public function subscribe( + string $projectId, + mixed $identifier, + string $subscriptionId, + array $roles, + array $channels, + array $queryGroup = [], + ?string $userId = null + ): void { if (!isset($this->subscriptions[$projectId])) { // Init Project $this->subscriptions[$projectId] = []; } @@ -106,10 +114,12 @@ class Realtime extends MessagingAdapter } } - // Update connection info + // Keep userId from onOpen/authentication when provided. + // Fallback to existing stored value for subsequent subscribe upserts. $this->connections[$identifier] = [ 'projectId' => $projectId, 'roles' => $roles, + 'userId' => $userId ?? ($this->connections[$identifier]['userId'] ?? ''), 'channels' => $channels ]; } diff --git a/tests/e2e/Services/Realtime/RealtimeBase.php b/tests/e2e/Services/Realtime/RealtimeBase.php index 95f3665e4c..b2d17c1e4a 100644 --- a/tests/e2e/Services/Realtime/RealtimeBase.php +++ b/tests/e2e/Services/Realtime/RealtimeBase.php @@ -101,18 +101,14 @@ trait RealtimeBase $client->close(); } - public function testConnectionFailureMissingChannels(): void + public function testConnectionSuccessMissingChannels(): void { $client = $this->getWebsocket([]); $payload = json_decode($client->receive(), true); $this->assertArrayHasKey("type", $payload); $this->assertArrayHasKey("data", $payload); - $this->assertEquals("error", $payload["type"]); - $this->assertEquals(1008, $payload["data"]["code"]); - $this->assertEquals("Missing channels", $payload["data"]["message"]); - \usleep(250000); // 250ms - $this->expectException(ConnectionException::class); // Check if server disconnected client + $this->assertEquals("connected", $payload["type"]); $client->close(); } From 9cf45816c267381c6b6a83c8240e507cf5709d60 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 9 Apr 2026 17:38:25 +0530 Subject: [PATCH 16/20] added triggering stats for messaging based subscription during the start --- app/realtime.php | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 4d25b07005..aa07e3c65c 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -712,6 +712,23 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId()); + $registerConnectionStats = static function (string $projectId, string $teamId, string $payloadJson) use ($register, $stats): void { + $register->get('telemetry.connectionCounter')->add(1); + $register->get('telemetry.connectionCreatedCounter')->add(1); + + $stats->set($projectId, [ + 'projectId' => $projectId, + 'teamId' => $teamId + ]); + $stats->incr($projectId, 'connections'); + $stats->incr($projectId, 'connectionsTotal'); + + triggerStats([ + METRIC_REALTIME_CONNECTIONS => 1, + METRIC_REALTIME_OUTBOUND => \strlen($payloadJson), + ], $projectId); + }; + /** * Channels Check */ @@ -728,6 +745,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $realtime->subscribe($project->getId(), $connection, '', $roles, [], [], $user->getId()); $server->send([$connection], $connectedPayloadJson); + $registerConnectionStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson); return; } @@ -773,20 +791,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, ]); $server->send([$connection], $connectedPayloadJson); - - $register->get('telemetry.connectionCounter')->add(1); - $register->get('telemetry.connectionCreatedCounter')->add(1); - - $stats->set($project->getId(), [ - 'projectId' => $project->getId(), - 'teamId' => $project->getAttribute('teamId') - ]); - $stats->incr($project->getId(), 'connections'); - $stats->incr($project->getId(), 'connectionsTotal'); - - $connectedOutboundBytes = \strlen($connectedPayloadJson); - - triggerStats([METRIC_REALTIME_CONNECTIONS => 1, METRIC_REALTIME_OUTBOUND => $connectedOutboundBytes], $project->getId()); + $registerConnectionStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson); } catch (Throwable $th) { From 410a050244f92e01ad31a64e3bc8e7b1d68b12cb Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 9 Apr 2026 18:04:01 +0530 Subject: [PATCH 17/20] updated --- app/realtime.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/realtime.php b/app/realtime.php index aa07e3c65c..09f0fb4bb9 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -734,16 +734,18 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, */ if (empty($channels)) { // in case of message based 'subscribe' channels will be empty at first and only projectId and roles will be available + $sanitizedUser = empty($user->getId()) ? null : $response->output($user, Response::MODEL_ACCOUNT); $connectedPayloadJson = json_encode([ 'type' => 'connected', 'data' => [ 'channels' => [], 'subscriptions' => [], - 'user' => $user + 'user' => $sanitizedUser ] ]); $realtime->subscribe($project->getId(), $connection, '', $roles, [], [], $user->getId()); + $realtime->connections[$connection]['authorization'] = $authorization; $server->send([$connection], $connectedPayloadJson); $registerConnectionStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson); return; From 912dbda1593a79df6ca0f4f32d9c189695c49be8 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 9 Apr 2026 18:16:09 +0530 Subject: [PATCH 18/20] updated type --- app/realtime.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/realtime.php b/app/realtime.php index 09f0fb4bb9..9ebc37d284 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -712,7 +712,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId()); - $registerConnectionStats = static function (string $projectId, string $teamId, string $payloadJson) use ($register, $stats): void { + $registerConnectionStats = static function (string $projectId, ?string $teamId, string $payloadJson) use ($register, $stats): void { $register->get('telemetry.connectionCounter')->add(1); $register->get('telemetry.connectionCreatedCounter')->add(1); From 7b3d9bae0399c47f41ab85c7ed747b078b61e3f4 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 10 Apr 2026 11:04:44 +0530 Subject: [PATCH 19/20] updated authorization --- app/realtime.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/realtime.php b/app/realtime.php index 9ebc37d284..75378bd7e4 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -1056,6 +1056,9 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $realtime->subscribe($projectId, $connection, $subscriptionId, $roles, $channels, $queries); } + // subscribe() overwrites the connection entry; restore auth so later onMessage uses the same context. + $realtime->connections[$connection]['authorization'] = $authorization; + $responsePayload = json_encode([ 'type' => 'response', 'data' => [ From 2e6f3f5c1472d7631878898d2b2976a3a7c28762 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 10 Apr 2026 11:13:03 +0530 Subject: [PATCH 20/20] typo --- app/realtime.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 75378bd7e4..81c81f8b98 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -712,7 +712,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId()); - $registerConnectionStats = static function (string $projectId, ?string $teamId, string $payloadJson) use ($register, $stats): void { + $updateStats = static function (string $projectId, ?string $teamId, string $payloadJson) use ($register, $stats): void { $register->get('telemetry.connectionCounter')->add(1); $register->get('telemetry.connectionCreatedCounter')->add(1); @@ -747,7 +747,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $realtime->subscribe($project->getId(), $connection, '', $roles, [], [], $user->getId()); $realtime->connections[$connection]['authorization'] = $authorization; $server->send([$connection], $connectedPayloadJson); - $registerConnectionStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson); + $updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson); return; } @@ -793,7 +793,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, ]); $server->send([$connection], $connectedPayloadJson); - $registerConnectionStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson); + $updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson); } catch (Throwable $th) {