diff --git a/app/realtime.php b/app/realtime.php index 4bd105beb1..b81ddf551c 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -480,11 +480,11 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $receivers = $realtime->getSubscribers($event); - // if (App::isDevelopment() && !empty($receivers)) { - // Console::log("[Debug][Worker {$workerId}] Receivers: " . count($receivers)); - // Console::log("[Debug][Worker {$workerId}] Receivers Connection IDs: " . json_encode($receivers)); - // Console::log("[Debug][Worker {$workerId}] Event: " . $payload); - // } + if (App::isDevelopment() && !empty($receivers)) { + Console::log("[Debug][Worker {$workerId}] Receivers: " . count($receivers)); + Console::log("[Debug][Worker {$workerId}] Receivers Connection IDs: " . json_encode($receivers)); + Console::log("[Debug][Worker {$workerId}] Event: " . $payload); + } $server->send( $receivers, diff --git a/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php b/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php index c887ca36d6..756245098f 100644 --- a/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php +++ b/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php @@ -46,16 +46,48 @@ class RuntimeQuery extends Query $attribute = $query->getAttribute(); $method = $query->getMethod(); $values = $query->getValues(); - if (!\array_key_exists($attribute, $payload)) { + + // during 'and' and 'or' attribute will not be present + if (in_array($method, [Query::TYPE_AND, Query::TYPE_OR])) { + switch ($method) { + case Query::TYPE_AND: + // All subqueries must evaluate to true + foreach ($query->getValues() as $subquery) { + if (!self::evaluateFilter($subquery, $payload)) { + return false; + } + } + return true; + + case Query::TYPE_OR: + // At least one subquery must evaluate to true + foreach ($query->getValues() as $subquery) { + if (self::evaluateFilter($subquery, $payload)) { + return true; + } + } + return false; + + default: + throw new \InvalidArgumentException( + "Unsupported query method: {$method}" + ); + } + } + + $hasAttribute = \array_key_exists($attribute, $payload); + if (!$hasAttribute) { return false; } + + // null can be a value as well $payloadAttributeValue = $payload[$attribute]; switch ($method) { case Query::TYPE_EQUAL: return self::anyMatch($values, fn ($value) => $payloadAttributeValue === $value); case Query::TYPE_NOT_EQUAL: - return self::anyMatch($values, fn ($value) => $payloadAttributeValue !== $value); + return !self::anyMatch($values, fn ($value) => $payloadAttributeValue === $value); case Query::TYPE_LESSER: return self::anyMatch($values, fn ($value) => $payloadAttributeValue < $value); @@ -75,26 +107,6 @@ class RuntimeQuery extends Query case Query::TYPE_IS_NOT_NULL: return $payloadAttributeValue !== null; - case Query::TYPE_AND: - foreach ($query->getValues() as $subquery) { - // if any evaluation gets to false then whole and is false - if (!self::evaluateFilter($subquery, $payload)) { - return false; - } - return true; - } - - // no break - case Query::TYPE_OR: - foreach ($query->getValues() as $subquery) { - // if any evaluation gets to true then whole or is true - if (self::evaluateFilter($subquery, $payload)) { - return true; - } - return false; - } - - // no break default: throw new \InvalidArgumentException( "Unsupported query method: {$method}" diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php new file mode 100644 index 0000000000..303c6067be --- /dev/null +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php @@ -0,0 +1,1550 @@ +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 { + $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, + ]); + + sleep(2); + + $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, + ]); + + sleep(2); + + $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, + ]); + + sleep(2); + + // 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, + ]); + + sleep(2); + + // 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, + ]); + + sleep(2); + + // 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, + ]); + + sleep(2); + + // 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, + ]); + + sleep(2); + + // 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, + ]); + + sleep(2); + + // 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, + ]); + + sleep(2); + + // 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, + ]); + + sleep(2); + + // 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, + ]); + + sleep(2); + + // 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 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 testExecutionChannelWithQuery() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Create function + $function = $this->client->call(Client::METHOD_POST, '/functions', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'functionId' => ID::unique(), + 'name' => 'Test Function', + 'execute' => ['users'], + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'timeout' => 10, + ]); + $functionId = $function['body']['$id'] ?? ''; + + $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'code' => $this->packageFunction('timeout'), + 'activate' => true + ]); + $deploymentId = $deployment['body']['$id'] ?? ''; + + // Poll until deployment is built + $this->assertEventually(function () use ($function, $deploymentId, $projectId) { + $deployment = $this->client->call(Client::METHOD_GET, '/functions/' . $function['body']['$id'] . '/deployments/' . $deploymentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('ready', $deployment['body']['status']); + }); + + // Subscribe with query for execution with response (not null) + $client = $this->getWebsocket(['executions'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::isNotNull('response')->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Execute function - should receive event when execution completes with response + $execution = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/executions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId + ], $this->getHeaders()), [ + 'async' => true + ]); + + // Wait for execution to complete + $event = json_decode($client->receive(), true); + if ($event['type'] === 'event' && isset($event['data']['payload']['response'])) { + $this->assertEquals('event', $event['type']); + $this->assertNotNull($event['data']['payload']['response']); + } + + $client->close(); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/functions/' . $functionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], []); + + $targetTeamId = ID::unique(); + + // Subscribe with query for specific team ID + $client = $this->getWebsocket(['teams'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$targetTeamId])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create team with matching ID - should receive event + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'teamId' => $targetTeamId, + 'name' => 'Query Test Team' + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($targetTeamId, $event['data']['payload']['$id']); + + // Create team with different ID - should NOT receive event + $otherTeamId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'teamId' => $otherTeamId, + 'name' => 'Other Team' + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } + + public function testMultipleQueriesWithOrLogic() + { + $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, + ]); + + sleep(2); + + $docId1 = ID::unique(); + $docId2 = ID::unique(); + + // Subscribe with multiple queries (OR logic - any query matching returns event) + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$docId1])->toString(), + Query::equal('$id', [$docId2])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create document with first ID - 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' => $docId1, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($docId1, $event['data']['payload']['$id']); + + // Create document with second ID - 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' => $docId2, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($docId2, $event['data']['payload']['$id']); + + // Create document with different ID - should NOT receive event + $otherDocId = 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' => $otherDocId, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } +} diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index b15389dd2f..112eed1ccd 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -3039,7 +3039,7 @@ class RealtimeCustomClientTest extends Scope sleep(1); try { - $client->receive(1); // 1 second timeout + $client->receive(); $this->fail('Should not receive any event after rollback'); } catch (TimeoutException $e) { // Expected - no event should be triggered diff --git a/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php b/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php new file mode 100644 index 0000000000..2156d862a5 --- /dev/null +++ b/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php @@ -0,0 +1,589 @@ + 'John', 'age' => 30]; + $result = RuntimeQuery::filter([], $payload); + $this->assertEquals($payload, $result); + } + + public function testFilterWithNoMatchingQuery(): void + { + $queries = [Query::equal('name', ['Jane'])]; + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter($queries, $payload); + $this->assertEquals([], $result); + } + + public function testFilterWithMatchingQuery(): void + { + $queries = [Query::equal('name', ['John'])]; + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter($queries, $payload); + $this->assertEquals($payload, $result); + } + + // TYPE_EQUAL tests + public function testEqualMatch(): void + { + $query = Query::equal('name', ['John']); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testEqualNoMatch(): void + { + $query = Query::equal('name', ['Jane']); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testEqualMultipleValuesMatch(): void + { + $query = Query::equal('status', ['active', 'pending', 'approved']); + $payload = ['status' => 'active']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testEqualMultipleValuesNoMatch(): void + { + $query = Query::equal('status', ['active', 'pending', 'approved']); + $payload = ['status' => 'rejected']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testEqualNumericValues(): void + { + $query = Query::equal('age', [30, 25, 35]); + $payload = ['age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testEqualBooleanValues(): void + { + $query = Query::equal('active', [true]); + $payload = ['active' => true]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testEqualMissingAttribute(): void + { + $query = Query::equal('missing', ['value']); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + // TYPE_NOT_EQUAL tests + public function testNotEqualMatch(): void + { + $query = Query::notEqual('name', ['Jane']); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testNotEqualNoMatch(): void + { + $query = Query::notEqual('name', ['John']); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testNotEqualMultipleValues(): void + { + // generally from the client side they will pass query strings via the realtime + // and Query::parse will be done first and parse doesn't allow multiple notEqual values + $query = Query::notEqual('status', ['rejected', 'cancelled']); + $payload = ['status' => 'active']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + + $query = Query::notEqual('status', ['active', 'pending']); + $payload = ['status' => 'active']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + // TYPE_LESSER tests + public function testLesserMatch(): void + { + $query = Query::lessThan('age', 30); + $payload = ['age' => 25]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testLesserNoMatch(): void + { + $query = Query::lessThan('age', 30); + $payload = ['age' => 35]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testLesserEqualValue(): void + { + $query = Query::lessThan('age', 30); + $payload = ['age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testLesserMultipleValues(): void + { + // Note: Query::lessThan only accepts single value, but RuntimeQuery's anyMatch supports arrays + // This test uses a single value as Query class requires + $query = Query::lessThan('age', 30); + $payload = ['age' => 25]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testLesserStringComparison(): void + { + $query = Query::lessThan('name', 'M'); + $payload = ['name' => 'A']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + // TYPE_LESSER_EQUAL tests + public function testLesserEqualMatch(): void + { + $query = Query::lessThanEqual('age', 30); + $payload = ['age' => 25]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testLesserEqualExactMatch(): void + { + $query = Query::lessThanEqual('age', 30); + $payload = ['age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testLesserEqualNoMatch(): void + { + $query = Query::lessThanEqual('age', 30); + $payload = ['age' => 35]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testLesserEqualMultipleValues(): void + { + // Note: Query::lessThanEqual only accepts single value + $query = Query::lessThanEqual('age', 30); + $payload = ['age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + // TYPE_GREATER tests + public function testGreaterMatch(): void + { + $query = Query::greaterThan('age', 30); + $payload = ['age' => 35]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testGreaterNoMatch(): void + { + $query = Query::greaterThan('age', 30); + $payload = ['age' => 25]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testGreaterEqualValue(): void + { + $query = Query::greaterThan('age', 30); + $payload = ['age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testGreaterMultipleValues(): void + { + // Note: Query::greaterThan only accepts single value + $query = Query::greaterThan('age', 20); + $payload = ['age' => 35]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + // TYPE_GREATER_EQUAL tests + public function testGreaterEqualMatch(): void + { + $query = Query::greaterThanEqual('age', 30); + $payload = ['age' => 35]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testGreaterEqualExactMatch(): void + { + $query = Query::greaterThanEqual('age', 30); + $payload = ['age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testGreaterEqualNoMatch(): void + { + $query = Query::greaterThanEqual('age', 30); + $payload = ['age' => 25]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testGreaterEqualMultipleValues(): void + { + // Note: Query::greaterThanEqual only accepts single value + $query = Query::greaterThanEqual('age', 20); + $payload = ['age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + // TYPE_IS_NULL tests + public function testIsNullMatch(): void + { + $query = Query::isNull('description'); + $payload = ['description' => null]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testIsNullNoMatch(): void + { + $query = Query::isNull('description'); + $payload = ['description' => 'Some text']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testIsNullMissingAttribute(): void + { + $query = Query::isNull('missing'); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + // TYPE_IS_NOT_NULL tests + public function testIsNotNullMatch(): void + { + $query = Query::isNotNull('description'); + $payload = ['description' => 'Some text']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testIsNotNullNoMatch(): void + { + $query = Query::isNotNull('description'); + $payload = ['description' => null]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testIsNotNullMissingAttribute(): void + { + $query = Query::isNotNull('missing'); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + // TYPE_AND tests + public function testAndAllMatch(): void + { + $query = Query::and([ + Query::equal('name', ['John']), + Query::equal('age', [30]) + ]); + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testAndOneFails(): void + { + $query = Query::and([ + Query::equal('name', ['John']), + Query::equal('age', [25]) + ]); + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testAndAllFail(): void + { + $query = Query::and([ + Query::equal('name', ['Jane']), + Query::equal('age', [25]) + ]); + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testAndMultipleConditions(): void + { + $query = Query::and([ + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + Query::isNotNull('email') + ]); + $payload = ['status' => 'active', 'age' => 25, 'email' => 'test@example.com']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testAndNestedAnd(): void + { + $query = Query::and([ + Query::equal('name', ['John']), + Query::and([ + Query::equal('age', [30]), + Query::equal('status', ['active']) + ]) + ]); + $payload = ['name' => 'John', 'age' => 30, 'status' => 'active']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + // TYPE_OR tests + public function testOrOneMatch(): void + { + $query = Query::or([ + Query::equal('name', ['John']), + Query::equal('name', ['Jane']) + ]); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testOrAllMatch(): void + { + $query = Query::or([ + Query::equal('status', ['active']), + Query::equal('status', ['pending']) + ]); + $payload = ['status' => 'active']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testOrAllFail(): void + { + $query = Query::or([ + Query::equal('name', ['Jane']), + Query::equal('age', [25]) + ]); + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testOrMultipleConditions(): void + { + $query = Query::or([ + Query::equal('status', ['active']), + Query::equal('status', ['pending']), + Query::equal('status', ['approved']) + ]); + $payload = ['status' => 'pending']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testOrNestedOr(): void + { + $query = Query::or([ + Query::equal('name', ['John']), + Query::or([ + Query::equal('name', ['Jane']), + Query::equal('name', ['Bob']) + ]) + ]); + $payload = ['name' => 'Bob']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testOrWithDifferentAttributes(): void + { + $query = Query::or([ + Query::equal('name', ['John']), + Query::equal('email', ['john@example.com']) + ]); + $payload = ['name' => 'Jane', 'email' => 'john@example.com']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + // Complex combinations + public function testAndOrCombination(): void + { + $query = Query::and([ + Query::equal('type', ['user']), + Query::or([ + Query::equal('status', ['active']), + Query::equal('status', ['pending']) + ]) + ]); + $payload = ['type' => 'user', 'status' => 'active']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testOrAndCombination(): void + { + $query = Query::or([ + Query::and([ + Query::equal('name', ['John']), + Query::equal('age', [30]) + ]), + Query::and([ + Query::equal('name', ['Jane']), + Query::equal('age', [25]) + ]) + ]); + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + // Edge cases + public function testMultipleQueriesFirstMatches(): void + { + $queries = [ + Query::equal('name', ['John']), + Query::equal('age', [25]) + ]; + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter($queries, $payload); + $this->assertEquals($payload, $result); + } + + public function testMultipleQueriesSecondMatches(): void + { + $queries = [ + Query::equal('name', ['Jane']), + Query::equal('age', [30]) + ]; + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter($queries, $payload); + $this->assertEquals($payload, $result); + } + + public function testMultipleQueriesNoneMatch(): void + { + $queries = [ + Query::equal('name', ['Jane']), + Query::equal('age', [25]) + ]; + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter($queries, $payload); + $this->assertEquals([], $result); + } + + public function testEmptyPayload(): void + { + $query = Query::equal('name', ['John']); + $payload = []; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testEmptyAndQuery(): void + { + $query = Query::and([]); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + // Empty AND should return true (all conditions pass vacuously) + $this->assertEquals($payload, $result); + } + + public function testEmptyOrQuery(): void + { + $query = Query::or([]); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + // Empty OR should return false (no conditions match) + $this->assertEquals([], $result); + } + + // Type-specific edge cases + public function testEqualWithZero(): void + { + $query = Query::equal('count', [0]); + $payload = ['count' => 0]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testEqualWithEmptyString(): void + { + $query = Query::equal('name', ['']); + $payload = ['name' => '']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testEqualWithFalse(): void + { + $query = Query::equal('active', [false]); + $payload = ['active' => false]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testComparisonWithFloat(): void + { + $query = Query::greaterThan('score', 8.5); + $payload = ['score' => 9.2]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testComparisonWithStringNumbers(): void + { + $query = Query::lessThan('version', '10'); + $payload = ['version' => '9']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } +}