diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index a03ccecfd9..f01b02ba21 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -22,9 +22,9 @@ class Realtime extends MessagingAdapter /** * Action suffixes recognized in channel names. A channel like `documents.create` * is split into base channel `documents` plus action `create`. Add new actions - * (e.g. `delete`) here to extend support — no other code change is required. + * here to extend support — no other code change is required. */ - public const SUPPORTED_ACTIONS = ['create', 'update', 'upsert']; + public const SUPPORTED_ACTIONS = ['create', 'update', 'upsert', 'delete']; /** * Connection Tree diff --git a/tests/e2e/Services/Realtime/RealtimeQueryBase.php b/tests/e2e/Services/Realtime/RealtimeQueryBase.php index 24d2a3511a..5ab5c26253 100644 --- a/tests/e2e/Services/Realtime/RealtimeQueryBase.php +++ b/tests/e2e/Services/Realtime/RealtimeQueryBase.php @@ -2827,7 +2827,7 @@ trait RealtimeQueryBase $clientMulti->close(); } - public function testChannelActionFilterUnsupportedActionTreatedAsLiteral(): void + public function testChannelActionFilterDeliversDeleteEvents(): void { $user = $this->getUser(); $session = $user['session'] ?? ''; @@ -2840,17 +2840,64 @@ trait RealtimeQueryBase ['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection(); - // `delete` is intentionally NOT in SUPPORTED_ACTIONS yet, so parseActionChannel - // leaves the channel name intact and treats it as a literal channel that no - // published event ever carries — the subscriber should receive nothing. - $client = $this->getWebsocket(['documents.delete'], $headers); - $connected = $this->assertConnectionStatusIfSupported($client); + $deleteChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.delete"; + $clientDelete = $this->getWebsocket([$deleteChannel], $headers); + $connected = $this->assertConnectionStatusIfSupported($clientDelete); if ($connected !== null) { - $this->assertContains('documents.delete', $connected['data']['channels']); + $this->assertContains($deleteChannel, $connected['data']['channels']); } $documentId = ID::unique(); - $this->createActor($databaseId, $collectionId, $documentId, 'No Delete Listener'); + $this->createActor($databaseId, $collectionId, $documentId, 'About To Be Deleted'); + + // Create event must not arrive — the action filter is `delete`. + try { + $clientDelete->receive(); + $this->fail('Delete subscriber should not receive a create event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + $this->client->call(Client::METHOD_DELETE, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders())); + + $deleteEvent = json_decode($clientDelete->receive(), true); + $this->assertEquals('event', $deleteEvent['type']); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$documentId}.delete", + $deleteEvent['data']['events'] + ); + $this->assertEquals($documentId, $deleteEvent['data']['payload']['$id']); + + $clientDelete->close(); + } + + public function testChannelActionFilterUnknownSuffixTreatedAsLiteral(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + ['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection(); + + // An unrecognised suffix is NOT in SUPPORTED_ACTIONS, so parseActionChannel + // leaves the channel name intact and treats it as a literal channel that no + // published event ever carries — the subscriber should receive nothing. + $client = $this->getWebsocket(['documents.bogus'], $headers); + $connected = $this->assertConnectionStatusIfSupported($client); + if ($connected !== null) { + $this->assertContains('documents.bogus', $connected['data']['channels']); + } + + $documentId = ID::unique(); + $this->createActor($databaseId, $collectionId, $documentId, 'No Bogus Listener'); $this->client->call(Client::METHOD_DELETE, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", array_merge([ 'content-type' => 'application/json', @@ -2859,7 +2906,7 @@ trait RealtimeQueryBase try { $client->receive(); - $this->fail('`documents.delete` is not (yet) a supported action channel and should not deliver.'); + $this->fail('Unrecognised action suffix should not deliver any events.'); } catch (TimeoutException $e) { $this->addToAssertionCount(1); } diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index 0706f8e89b..1fde8e6bc2 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -523,17 +523,22 @@ class MessagingTest extends TestCase $this->assertSame(['documents', 'create'], Realtime::parseActionChannel('documents.create')); $this->assertSame(['documents', 'update'], Realtime::parseActionChannel('documents.update')); $this->assertSame(['documents', 'upsert'], Realtime::parseActionChannel('documents.upsert')); + $this->assertSame(['documents', 'delete'], Realtime::parseActionChannel('documents.delete')); $this->assertSame( ['databases.X.collections.Y.documents.Z', 'create'], Realtime::parseActionChannel('databases.X.collections.Y.documents.Z.create') ); + $this->assertSame( + ['databases.X.collections.Y.documents.Z', 'delete'], + Realtime::parseActionChannel('databases.X.collections.Y.documents.Z.delete') + ); // No action suffix → unchanged with '*' default. $this->assertSame(['documents', '*'], Realtime::parseActionChannel('documents')); $this->assertSame(['documents.789', '*'], Realtime::parseActionChannel('documents.789')); - // Unrecognised suffix (e.g. delete is not yet supported) → unchanged. - $this->assertSame(['documents.delete', '*'], Realtime::parseActionChannel('documents.delete')); + // Unrecognised suffix → unchanged (treated as literal channel name). + $this->assertSame(['documents.bogus', '*'], Realtime::parseActionChannel('documents.bogus')); } public function testActionChannelFiltersByEventAction(): void @@ -590,6 +595,44 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('sub-create', $receivers[1]); } + public function testActionChannelDeleteFilter(): void + { + $realtime = new Realtime(); + + $realtime->subscribe( + '1', + 1, + 'sub-delete', + [Role::any()->toString()], + ['documents.delete'], + ); + + $deleteEvent = [ + 'project' => '1', + 'roles' => [Role::any()->toString()], + 'data' => [ + 'channels' => ['documents'], + 'events' => [ + 'databases.db.collections.col.documents.doc.delete', + 'databases.*.collections.*.documents.*.delete', + ], + 'payload' => ['$id' => 'doc'], + ], + ]; + + $receivers = $realtime->getSubscribers($deleteEvent); + $this->assertArrayHasKey(1, $receivers); + $this->assertArrayHasKey('sub-delete', $receivers[1]); + + // Other actions on the same base channel should not match the delete filter. + $createEvent = $deleteEvent; + $createEvent['data']['events'] = [ + 'databases.db.collections.col.documents.doc.create', + 'databases.*.collections.*.documents.*.create', + ]; + $this->assertEmpty($realtime->getSubscribers($createEvent)); + } + public function testActionChannelHonorsResourceId(): void { $realtime = new Realtime();