diff --git a/app/controllers/api/vcs.php b/app/controllers/api/vcs.php index 72b996f7eb..373f413304 100644 --- a/app/controllers/api/vcs.php +++ b/app/controllers/api/vcs.php @@ -2,14 +2,12 @@ use Appwrite\Auth\OAuth2\Github as OAuth2Github; use Appwrite\Event\Build; -use Appwrite\Event\Delete; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\MethodType; use Appwrite\SDK\Response as SDKResponse; -use Appwrite\Utopia\Database\Validator\Queries\Installations; use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; use Appwrite\Vcs\Comment; @@ -22,15 +20,12 @@ use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Exception\Duplicate; -use Utopia\Database\Exception\Order as OrderException; -use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Queries; -use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; use Utopia\Detector\Detection\Framework\Analog; @@ -1612,158 +1607,6 @@ Http::post('/v1/vcs/github/events') } ); -Http::get('/v1/vcs/installations') - ->desc('List installations') - ->groups(['api', 'vcs']) - ->label('scope', 'vcs.read') - ->label('sdk', new Method( - namespace: 'vcs', - group: 'installations', - name: 'listInstallations', - description: '/docs/references/vcs/list-installations.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_INSTALLATION_LIST, - ) - ] - )) - ->param('queries', [], new Installations(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Installations::ALLOWED_ATTRIBUTES), true) - ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) - ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) - ->inject('response') - ->inject('project') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->action(function (array $queries, string $search, bool $includeTotal, Response $response, Document $project, Database $dbForProject, Database $dbForPlatform) { - try { - $queries = Query::parseQueries($queries); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - $queries[] = Query::equal('projectInternalId', [$project->getSequence()]); - - if (!empty($search)) { - $queries[] = Query::search('search', $search); - } - - /** - * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries - */ - $cursor = \array_filter($queries, function ($query) { - return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); - }); - $cursor = reset($cursor); - if ($cursor) { - /** @var Query $cursor */ - - $validator = new Cursor(); - if (!$validator->isValid($cursor)) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); - } - - $installationId = $cursor->getValue(); - $cursorDocument = $dbForPlatform->getDocument('installations', $installationId); - - if ($cursorDocument->isEmpty()) { - throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Installation '{$installationId}' for the 'cursor' value not found."); - } - - $cursor->setValue($cursorDocument); - } - - $filterQueries = Query::groupByType($queries)['filters']; - try { - $results = $dbForPlatform->find('installations', $queries); - $total = $includeTotal ? $dbForPlatform->count('installations', $filterQueries, APP_LIMIT_COUNT) : 0; - } catch (OrderException $e) { - throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); - } - - $response->dynamic(new Document([ - 'installations' => $results, - 'total' => $total, - ]), Response::MODEL_INSTALLATION_LIST); - }); - -Http::get('/v1/vcs/installations/:installationId') - ->desc('Get installation') - ->groups(['api', 'vcs']) - ->label('scope', 'vcs.read') - ->label('sdk', new Method( - namespace: 'vcs', - group: 'installations', - name: 'getInstallation', - description: '/docs/references/vcs/get-installation.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_INSTALLATION, - ) - ] - )) - ->param('installationId', '', new Text(256), 'Installation Id') - ->inject('response') - ->inject('project') - ->inject('dbForPlatform') - ->action(function (string $installationId, Response $response, Document $project, Database $dbForPlatform) { - $installation = $dbForPlatform->getDocument('installations', $installationId); - - if ($installation === false || $installation->isEmpty()) { - throw new Exception(Exception::INSTALLATION_NOT_FOUND); - } - - if ($installation->getAttribute('projectInternalId') !== $project->getSequence()) { - throw new Exception(Exception::INSTALLATION_NOT_FOUND); - } - - $response->dynamic($installation, Response::MODEL_INSTALLATION); - }); - -Http::delete('/v1/vcs/installations/:installationId') - ->desc('Delete installation') - ->groups(['api', 'vcs']) - ->label('scope', 'vcs.write') - ->label('sdk', new Method( - namespace: 'vcs', - group: 'installations', - name: 'deleteInstallation', - description: '/docs/references/vcs/delete-installation.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - contentType: ContentType::NONE - )) - ->param('installationId', '', new Text(256), 'Installation Id') - ->inject('response') - ->inject('project') - ->inject('dbForPlatform') - ->inject('queueForDeletes') - ->action(function (string $installationId, Response $response, Document $project, Database $dbForPlatform, Delete $queueForDeletes) { - $installation = $dbForPlatform->getDocument('installations', $installationId); - - if ($installation->isEmpty()) { - throw new Exception(Exception::INSTALLATION_NOT_FOUND); - } - - if (!$dbForPlatform->deleteDocument('installations', $installation->getId())) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove installation from DB'); - } - - $queueForDeletes - ->setType(DELETE_TYPE_DOCUMENT) - ->setDocument($installation); - - $response->noContent(); - }); - Http::patch('/v1/vcs/github/installations/:installationId/repositories/:repositoryId') ->desc('Update external deployment (authorize)') ->groups(['api', 'vcs']) diff --git a/app/init/constants.php b/app/init/constants.php index 7912cb823a..0ee5271d7f 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -369,6 +369,7 @@ const RESOURCE_TYPE_TOPICS = 'topics'; const RESOURCE_TYPE_SUBSCRIBERS = 'subscribers'; const RESOURCE_TYPE_MESSAGES = 'messages'; const RESOURCE_TYPE_EXECUTIONS = 'executions'; +const RESOURCE_TYPE_VCS = 'vcs'; // Resource types for Tokens const TOKENS_RESOURCE_TYPE_FILES = 'files'; diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 32329bda54..2bd0bc9cb8 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -377,7 +377,7 @@ class Realtime extends MessagingAdapter $subscriptionsByIndex = []; foreach ($channelNames as $channel) { - $channelSubscriptions = $getQueryParam($channel); + $channelSubscriptions = $getQueryParam(str_replace(".", "_", $channel)); // Backward compatibility: if no channel-specific query params, treat as subscription 0 with select("*") if ($channelSubscriptions === null) { diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php index 35347b4023..9982b0bf1e 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -14,6 +14,7 @@ use Appwrite\Platform\Modules\Proxy; use Appwrite\Platform\Modules\Sites; use Appwrite\Platform\Modules\Storage; use Appwrite\Platform\Modules\Tokens; +use Appwrite\Platform\Modules\VCS; use Utopia\Platform\Platform; class Appwrite extends Platform @@ -32,5 +33,6 @@ class Appwrite extends Platform $this->addModule(new Proxy\Module()); $this->addModule(new Tokens\Module()); $this->addModule(new Storage\Module()); + $this->addModule(new VCS\Module()); } } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Delete.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Delete.php new file mode 100644 index 0000000000..26a9476941 --- /dev/null +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Delete.php @@ -0,0 +1,78 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/vcs/installations/:installationId') + ->desc('Delete installation') + ->groups(['api', 'vcs']) + ->label('scope', 'vcs.write') + ->label('resourceType', RESOURCE_TYPE_VCS) + ->label('sdk', new Method( + namespace: 'vcs', + group: 'installations', + name: 'deleteInstallation', + description: '/docs/references/vcs/delete-installation.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_NOCONTENT, + model: Response::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('installationId', '', new Text(256), 'Installation Id') + ->inject('response') + ->inject('dbForPlatform') + ->inject('queueForDeletes') + ->callback($this->action(...)); + } + + public function action( + string $installationId, + Response $response, + Database $dbForPlatform, + DeleteEvent $queueForDeletes + ) { + $installation = $dbForPlatform->getDocument('installations', $installationId); + + if ($installation->isEmpty()) { + throw new Exception(Exception::INSTALLATION_NOT_FOUND); + } + + if (!$dbForPlatform->deleteDocument('installations', $installation->getId())) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove installation from DB'); + } + + $queueForDeletes + ->setType(DELETE_TYPE_DOCUMENT) + ->setDocument($installation); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php new file mode 100644 index 0000000000..7bb2dedaf5 --- /dev/null +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php @@ -0,0 +1,72 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vcs/installations/:installationId') + ->desc('Get installation') + ->groups(['api', 'vcs']) + ->label('scope', 'vcs.read') + ->label('resourceType', RESOURCE_TYPE_VCS) + ->label('sdk', new Method( + namespace: 'vcs', + group: 'installations', + name: 'getInstallation', + description: '/docs/references/vcs/get-installation.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_INSTALLATION, + ) + ] + )) + ->param('installationId', '', new Text(256), 'Installation Id') + ->inject('response') + ->inject('project') + ->inject('dbForPlatform') + ->callback($this->action(...)); + } + + public function action( + string $installationId, + Response $response, + Document $project, + Database $dbForPlatform + ) { + $installation = $dbForPlatform->getDocument('installations', $installationId); + + if ($installation === false || $installation->isEmpty()) { + throw new Exception(Exception::INSTALLATION_NOT_FOUND); + } + + if ($installation->getAttribute('projectInternalId') !== $project->getSequence()) { + throw new Exception(Exception::INSTALLATION_NOT_FOUND); + } + + $response->dynamic($installation, Response::MODEL_INSTALLATION); + } +} diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/XList.php new file mode 100644 index 0000000000..628459fb27 --- /dev/null +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/XList.php @@ -0,0 +1,120 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vcs/installations') + ->desc('List installations') + ->groups(['api', 'vcs']) + ->label('scope', 'vcs.read') + ->label('resourceType', RESOURCE_TYPE_VCS) + ->label('sdk', new Method( + namespace: 'vcs', + group: 'installations', + name: 'listInstallations', + description: '/docs/references/vcs/list-installations.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_INSTALLATION_LIST, + ) + ] + )) + ->param('queries', [], new Installations(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Installations::ALLOWED_ATTRIBUTES), true) + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('project') + ->inject('dbForPlatform') + ->callback($this->action(...)); + } + + public function action( + array $queries, + string $search, + bool $includeTotal, + Response $response, + Document $project, + Database $dbForPlatform + ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $queries[] = Query::equal('projectInternalId', [$project->getSequence()]); + + if (!empty($search)) { + $queries[] = Query::search('search', $search); + } + + /** + * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries + */ + $cursor = \array_filter($queries, function ($query) { + return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); + }); + $cursor = reset($cursor); + if ($cursor) { + /** @var Query $cursor */ + + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $installationId = $cursor->getValue(); + $cursorDocument = $dbForPlatform->getDocument('installations', $installationId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Installation '{$installationId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + try { + $results = $dbForPlatform->find('installations', $queries); + $total = $includeTotal ? $dbForPlatform->count('installations', $filterQueries, APP_LIMIT_COUNT) : 0; + } catch (OrderException $e) { + throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); + } + + $response->dynamic(new Document([ + 'installations' => $results, + 'total' => $total, + ]), Response::MODEL_INSTALLATION_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/VCS/Module.php b/src/Appwrite/Platform/Modules/VCS/Module.php new file mode 100644 index 0000000000..9f43a83da9 --- /dev/null +++ b/src/Appwrite/Platform/Modules/VCS/Module.php @@ -0,0 +1,14 @@ +addService('http', new Http()); + } +} diff --git a/src/Appwrite/Platform/Modules/VCS/Services/Http.php b/src/Appwrite/Platform/Modules/VCS/Services/Http.php new file mode 100644 index 0000000000..3630a5b32f --- /dev/null +++ b/src/Appwrite/Platform/Modules/VCS/Services/Http.php @@ -0,0 +1,21 @@ +type = Service::TYPE_HTTP; + + // Installations + $this->addAction(GetInstallation::getName(), new GetInstallation()); + $this->addAction(ListInstallations::getName(), new ListInstallations()); + $this->addAction(DeleteInstallation::getName(), new DeleteInstallation()); + } +} diff --git a/src/Appwrite/Platform/Workers/Certificates.php b/src/Appwrite/Platform/Workers/Certificates.php index bfa6bf87c7..4f898c7e98 100644 --- a/src/Appwrite/Platform/Workers/Certificates.php +++ b/src/Appwrite/Platform/Workers/Certificates.php @@ -290,9 +290,11 @@ class Certificates extends Action $certificate->setAttribute('domain', $domain->get()); } + $date = \date('H:i:s'); + $logs = "\033[90m[{$date}] \033[97mProcessing SSL certificate issuance. \033[0m\n"; + try { - $date = \date('H:i:s'); - $certificate->setAttribute('logs', "\033[90m[{$date}] \033[97mCertificate generation started. \033[0m\n"); + $certificate->setAttribute('logs', $logs); // Persist ASAP so that logs are reset in retry flow and user can see the latest logs on Console. $certificate = $this->upsertCertificate($rule, $certificate, $dbForPlatform); @@ -314,10 +316,16 @@ class Certificates extends Action $certName = ID::unique(); $renewDate = $certificates->issueCertificate($certName, $domain->get(), $domainType); + $date = \date('H:i:s'); // If certificate is generated instantly, we can mark the rule as 'verified'. if ($certificates->isInstantGeneration($domain->get(), $domainType)) { $rule->setAttribute('status', RULE_STATUS_VERIFIED); - $certificate->setAttribute('logs', 'Certificate successfully generated.'); + $logs .= "\033[90m[{$date}] \033[97mSSL certificate successfully issued. \033[0m\n"; + $certificate->setAttribute('logs', $logs); + } else { + // Delayed generation: third-party handles certificate issuance asynchronously + $logs .= "\033[90m[{$date}] \033[97mSSL certificate is being issued. This usually takes a few minutes — no action needed on your end. We'll periodically check and update the status. \033[0m\n"; + $certificate->setAttribute('logs', $logs); } $certificate->setAttributes([ @@ -326,16 +334,14 @@ class Certificates extends Action 'renewDate' => $renewDate, ]); } catch (Throwable $e) { - $logs = $e->getMessage(); - $currentLogs = $certificate->getAttribute('logs', ''); $date = \date('H:i:s'); - $errorMessage = "\033[90m[{$date}] \033[31mCertificate generation failed: \033[0m\n"; + $logs .= "\033[90m[{$date}] \033[31mSSL certificate issuance failed: \033[0m\n"; + $logs .= \mb_strcut($e->getMessage(), 0, 500000); // Limit to 500kb $attempts = $certificate->getAttribute('attempts', 0) + 1; // Increase attempts count // Update attributes on certificate document $certificate->setAttributes([ - 'logs' => $currentLogs . $errorMessage . \mb_strcut($logs, 0, 500000), // Limit to 500kb 'attempts' => $attempts, 'renewDate' => DateTime::now(), // Store current time as renew date to ensure another attempt in next maintenance cycle. ]); @@ -348,14 +354,13 @@ class Certificates extends Action throw $e; } finally { - // All actions result in new 'updated' date - $certificate->setAttribute('updated', DateTime::now()); - // Save certificate document to database + // Update certificate document with logs + $certificate->setAttribute('logs', $logs); $this->upsertCertificate($rule, $certificate, $dbForPlatform); - // Ensure certificate is associated with the rule - $rule->setAttribute('certificateId', $certificate->getId()); // Update rule and emit events + $rule->setAttribute('certificateId', $certificate->getId()); + $rule->setAttribute('logs', $logs); $this->updateRuleAndSendEvents($rule, $dbForPlatform, $queueForEvents, $queueForWebhooks, $queueForFunctions, $queueForRealtime); } } diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php index e93e955f1a..37ea1e2e05 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php @@ -1234,6 +1234,183 @@ class RealtimeCustomClientQueryTest extends Scope $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, + ]); + + sleep(2); + + // 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, + ]); + + sleep(2); + + $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();