2021-06-29 13:11:14 +00:00
< ? php
2022-08-01 10:22:04 +00:00
namespace Tests\Unit\Messaging ;
2021-06-29 13:11:14 +00:00
use Appwrite\Messaging\Adapter\Realtime ;
use PHPUnit\Framework\TestCase ;
2024-03-06 17:34:21 +00:00
use Utopia\Database\Document ;
2022-12-14 15:42:25 +00:00
use Utopia\Database\Helpers\ID ;
2022-12-14 16:04:06 +00:00
use Utopia\Database\Helpers\Permission ;
use Utopia\Database\Helpers\Role ;
2021-06-29 13:11:14 +00:00
class MessagingTest extends TestCase
{
2026-04-27 12:05:56 +00:00
public function setUp () : void
2021-06-29 13:11:14 +00:00
{
}
2026-04-27 12:05:56 +00:00
public function tearDown () : void
2021-06-29 13:11:14 +00:00
{
}
2026-04-27 12:05:56 +00:00
public function testUser () : void
2021-06-29 13:11:14 +00:00
{
$realtime = new Realtime ();
$realtime -> subscribe (
'1' ,
1 ,
2026-02-03 08:46:35 +00:00
ID :: unique (),
2022-08-19 04:04:33 +00:00
[
Role :: user ( ID :: custom ( '123' )) -> toString (),
Role :: users () -> toString (),
Role :: team ( ID :: custom ( 'abc' )) -> toString (),
Role :: team ( ID :: custom ( 'abc' ), 'administrator' ) -> toString (),
Role :: team ( ID :: custom ( 'abc' ), 'moderator' ) -> toString (),
Role :: team ( ID :: custom ( 'def' )) -> toString (),
Role :: team ( ID :: custom ( 'def' ), 'guest' ) -> toString (),
],
2026-02-03 08:46:35 +00:00
// Pass plain channel names, Realtime::subscribe will normalize them
[ 'files' , 'documents' , 'documents.789' , 'account.123' ]
2021-06-29 13:11:14 +00:00
);
$event = [
'project' => '1' ,
2022-08-19 04:04:33 +00:00
'roles' => [ Role :: any () -> toString ()],
2021-06-29 13:11:14 +00:00
'data' => [
'channels' => [
0 => 'account.123' ,
2026-04-27 12:05:56 +00:00
]
]
2021-06-29 13:11:14 +00:00
];
2026-01-29 06:08:20 +00:00
$receivers = array_keys ( $realtime -> getSubscribers ( $event ));
2021-06-29 13:11:14 +00:00
$this -> assertCount ( 1 , $receivers );
$this -> assertEquals ( 1 , $receivers [ 0 ]);
2022-08-19 04:04:33 +00:00
$event [ 'roles' ] = [ Role :: users () -> toString ()];
2021-06-29 13:11:14 +00:00
2026-01-29 06:08:20 +00:00
$receivers = array_keys ( $realtime -> getSubscribers ( $event ));
2021-06-29 13:11:14 +00:00
$this -> assertCount ( 1 , $receivers );
$this -> assertEquals ( 1 , $receivers [ 0 ]);
2022-08-19 04:04:33 +00:00
$event [ 'roles' ] = [ Role :: user ( ID :: custom ( '123' )) -> toString ()];
2021-06-29 13:11:14 +00:00
2026-01-29 06:08:20 +00:00
$receivers = array_keys ( $realtime -> getSubscribers ( $event ));
2021-06-29 13:11:14 +00:00
$this -> assertCount ( 1 , $receivers );
$this -> assertEquals ( 1 , $receivers [ 0 ]);
2022-08-19 04:04:33 +00:00
$event [ 'roles' ] = [ Role :: team ( ID :: custom ( 'abc' )) -> toString ()];
2021-06-29 13:11:14 +00:00
2026-01-29 06:08:20 +00:00
$receivers = array_keys ( $realtime -> getSubscribers ( $event ));
2021-06-29 13:11:14 +00:00
$this -> assertCount ( 1 , $receivers );
$this -> assertEquals ( 1 , $receivers [ 0 ]);
2022-08-19 04:04:33 +00:00
$event [ 'roles' ] = [ Role :: team ( ID :: custom ( 'abc' ), 'administrator' ) -> toString ()];
2021-06-29 13:11:14 +00:00
2026-01-29 06:08:20 +00:00
$receivers = array_keys ( $realtime -> getSubscribers ( $event ));
2021-06-29 13:11:14 +00:00
$this -> assertCount ( 1 , $receivers );
$this -> assertEquals ( 1 , $receivers [ 0 ]);
2022-08-19 04:04:33 +00:00
$event [ 'roles' ] = [ Role :: team ( ID :: custom ( 'abc' ), 'moderator' ) -> toString ()];
2021-06-29 13:11:14 +00:00
2026-01-29 06:08:20 +00:00
$receivers = array_keys ( $realtime -> getSubscribers ( $event ));
2021-06-29 13:11:14 +00:00
$this -> assertCount ( 1 , $receivers );
$this -> assertEquals ( 1 , $receivers [ 0 ]);
2022-08-19 04:04:33 +00:00
$event [ 'roles' ] = [ Role :: team ( ID :: custom ( 'def' )) -> toString ()];
2021-06-29 13:11:14 +00:00
2026-01-29 06:08:20 +00:00
$receivers = array_keys ( $realtime -> getSubscribers ( $event ));
2021-06-29 13:11:14 +00:00
$this -> assertCount ( 1 , $receivers );
$this -> assertEquals ( 1 , $receivers [ 0 ]);
2022-08-19 04:04:33 +00:00
$event [ 'roles' ] = [ Role :: team ( ID :: custom ( 'def' ), 'guest' ) -> toString ()];
2021-06-29 13:11:14 +00:00
2026-01-29 06:08:20 +00:00
$receivers = array_keys ( $realtime -> getSubscribers ( $event ));
2021-06-29 13:11:14 +00:00
$this -> assertCount ( 1 , $receivers );
$this -> assertEquals ( 1 , $receivers [ 0 ]);
2022-08-19 04:04:33 +00:00
$event [ 'roles' ] = [ Role :: user ( ID :: custom ( '456' )) -> toString ()];
2021-06-29 13:11:14 +00:00
2026-01-29 06:08:20 +00:00
$receivers = array_keys ( $realtime -> getSubscribers ( $event ));
2021-06-29 13:11:14 +00:00
$this -> assertEmpty ( $receivers );
2022-08-19 04:04:33 +00:00
$event [ 'roles' ] = [ Role :: team ( ID :: custom ( 'def' ), 'member' ) -> toString ()];
2021-06-29 13:11:14 +00:00
2026-01-29 06:08:20 +00:00
$receivers = array_keys ( $realtime -> getSubscribers ( $event ));
2021-06-29 13:11:14 +00:00
$this -> assertEmpty ( $receivers );
2022-08-19 04:04:33 +00:00
$event [ 'roles' ] = [ Role :: any () -> toString ()];
2021-06-29 13:11:14 +00:00
$event [ 'data' ][ 'channels' ] = [ 'documents.123' ];
2026-01-29 06:08:20 +00:00
$receivers = array_keys ( $realtime -> getSubscribers ( $event ));
2021-06-29 13:11:14 +00:00
$this -> assertEmpty ( $receivers );
$event [ 'data' ][ 'channels' ] = [ 'documents.789' ];
2026-01-29 06:08:20 +00:00
$receivers = array_keys ( $realtime -> getSubscribers ( $event ));
2021-06-29 13:11:14 +00:00
$this -> assertCount ( 1 , $receivers );
$this -> assertEquals ( 1 , $receivers [ 0 ]);
$event [ 'project' ] = '2' ;
2026-01-29 06:08:20 +00:00
$receivers = array_keys ( $realtime -> getSubscribers ( $event ));
2021-06-29 13:11:14 +00:00
$this -> assertEmpty ( $receivers );
$realtime -> unsubscribe ( 2 );
$this -> assertCount ( 1 , $realtime -> connections );
$this -> assertCount ( 7 , $realtime -> subscriptions [ '1' ]);
$realtime -> unsubscribe ( 1 );
$this -> assertEmpty ( $realtime -> connections );
$this -> assertEmpty ( $realtime -> subscriptions );
}
2021-06-30 11:36:58 +00:00
2026-04-27 12:05:56 +00:00
public function testSubscribeUnionsChannelsAndRoles () : void
2026-04-20 12:08:01 +00:00
{
$realtime = new Realtime ();
$realtime -> subscribe (
'1' ,
1 ,
'sub-a' ,
[ Role :: user ( ID :: custom ( '123' )) -> toString ()],
[ 'documents' ],
);
$realtime -> subscribe (
'1' ,
1 ,
'sub-b' ,
[ Role :: users () -> toString ()],
[ 'files' ],
);
$connection = $realtime -> connections [ 1 ];
$this -> assertContains ( 'documents' , $connection [ 'channels' ]);
$this -> assertContains ( 'files' , $connection [ 'channels' ]);
$this -> assertContains ( Role :: user ( ID :: custom ( '123' )) -> toString (), $connection [ 'roles' ]);
$this -> assertContains ( Role :: users () -> toString (), $connection [ 'roles' ]);
$this -> assertCount ( 2 , $connection [ 'channels' ]);
$this -> assertCount ( 2 , $connection [ 'roles' ]);
}
2026-04-27 12:05:56 +00:00
public function testUnsubscribeSubscriptionRemovesOnlyOneSubscription () : void
2026-04-20 12:08:01 +00:00
{
$realtime = new Realtime ();
$realtime -> subscribe (
'1' ,
1 ,
'sub-a' ,
[ Role :: user ( ID :: custom ( '123' )) -> toString ()],
[ 'documents' ],
);
$realtime -> subscribe (
'1' ,
1 ,
'sub-b' ,
[ Role :: users () -> toString ()],
[ 'files' ],
);
$removed = $realtime -> unsubscribeSubscription ( 1 , 'sub-a' );
$this -> assertTrue ( $removed );
$this -> assertArrayHasKey ( 1 , $realtime -> connections );
// sub-a is fully cleaned from the tree
$this -> assertArrayNotHasKey (
Role :: user ( ID :: custom ( '123' )) -> toString (),
$realtime -> subscriptions [ '1' ]
);
// sub-b still delivers
$event = [
'project' => '1' ,
'roles' => [ Role :: users () -> toString ()],
'data' => [
'channels' => [ 'files' ],
],
];
$receivers = array_keys ( $realtime -> getSubscribers ( $event ));
$this -> assertEquals ([ 1 ], $receivers );
// Channels recomputed: sub-a's channel is gone
$this -> assertSame ([ 'files' ], $realtime -> connections [ 1 ][ 'channels' ]);
// Roles are connection-level auth context — union of both subscribe calls preserved
$this -> assertContains ( Role :: user ( ID :: custom ( '123' )) -> toString (), $realtime -> connections [ 1 ][ 'roles' ]);
$this -> assertContains ( Role :: users () -> toString (), $realtime -> connections [ 1 ][ 'roles' ]);
}
2026-04-27 12:05:56 +00:00
public function testUnsubscribeSubscriptionIsIdempotent () : void
2026-04-20 12:08:01 +00:00
{
$realtime = new Realtime ();
$realtime -> subscribe (
'1' ,
1 ,
'sub-a' ,
[ Role :: users () -> toString ()],
[ 'documents' ],
);
$this -> assertFalse ( $realtime -> unsubscribeSubscription ( 1 , 'does-not-exist' ));
$this -> assertFalse ( $realtime -> unsubscribeSubscription ( 99 , 'sub-a' ));
// Original sub is untouched
$event = [
'project' => '1' ,
'roles' => [ Role :: users () -> toString ()],
'data' => [
'channels' => [ 'documents' ],
],
];
$this -> assertEquals ([ 1 ], array_keys ( $realtime -> getSubscribers ( $event )));
}
2026-04-27 12:05:56 +00:00
public function testUnsubscribeSubscriptionKeepsConnectionWhenLastSubRemoved () : void
2026-04-20 12:08:01 +00:00
{
$realtime = new Realtime ();
$realtime -> subscribe (
'1' ,
1 ,
'sub-a' ,
[ Role :: users () -> toString ()],
[ 'documents' ],
);
$this -> assertTrue ( $realtime -> unsubscribeSubscription ( 1 , 'sub-a' ));
$this -> assertArrayHasKey ( 1 , $realtime -> connections );
$this -> assertSame ([], $realtime -> connections [ 1 ][ 'channels' ]);
// Roles preserved so a later resubscribe on the same connection still has auth context
$this -> assertSame ([ Role :: users () -> toString ()], $realtime -> connections [ 1 ][ 'roles' ]);
$this -> assertArrayNotHasKey ( '1' , $realtime -> subscriptions );
}
2026-04-27 12:05:56 +00:00
public function testResubscribeAfterUnsubscribingLastSubDelivers () : void
2026-04-20 12:08:01 +00:00
{
$realtime = new Realtime ();
$realtime -> subscribe (
'1' ,
1 ,
'sub-a' ,
[ Role :: users () -> toString ()],
[ 'documents' ],
);
$this -> assertTrue ( $realtime -> unsubscribeSubscription ( 1 , 'sub-a' ));
// Simulate the message-based subscribe path reading stored roles
$storedRoles = $realtime -> connections [ 1 ][ 'roles' ];
$this -> assertNotEmpty ( $storedRoles , 'connection roles must survive per-subscription removal' );
$realtime -> subscribe ( '1' , 1 , 'sub-b' , $storedRoles , [ 'files' ]);
$event = [
'project' => '1' ,
'roles' => [ Role :: users () -> toString ()],
'data' => [
'channels' => [ 'files' ],
],
];
$this -> assertEquals ([ 1 ], array_keys ( $realtime -> getSubscribers ( $event )));
}
2026-04-27 12:05:56 +00:00
public function testSubscribeAfterOnOpenEmptySentinelPreservesUnion () : void
2026-04-20 12:08:01 +00:00
{
$realtime = new Realtime ();
// Mirrors the onOpen empty-channels path: subscribe with '' id, empty channels
$realtime -> subscribe (
'1' ,
1 ,
'' ,
[ Role :: users () -> toString ()],
[],
[],
'user-123' ,
);
// Now a real subscription comes in via the subscribe message type
$realtime -> subscribe (
'1' ,
1 ,
'sub-a' ,
[ Role :: user ( ID :: custom ( 'user-123' )) -> toString ()],
[ 'documents' ],
);
$this -> assertSame ( 'user-123' , $realtime -> connections [ 1 ][ 'userId' ]);
$this -> assertContains ( 'documents' , $realtime -> connections [ 1 ][ 'channels' ]);
$this -> assertContains ( Role :: users () -> toString (), $realtime -> connections [ 1 ][ 'roles' ]);
$this -> assertContains ( Role :: user ( ID :: custom ( 'user-123' )) -> toString (), $realtime -> connections [ 1 ][ 'roles' ]);
}
2026-04-27 12:05:56 +00:00
public function testConvertChannelsGuest () : void
2021-06-30 11:36:58 +00:00
{
$user = new Document ([
2026-04-27 12:05:56 +00:00
'$id' => ''
2021-06-30 11:36:58 +00:00
]);
$channels = [
0 => 'files' ,
1 => 'documents' ,
2 => 'documents.789' ,
3 => 'account' ,
2026-04-27 12:05:56 +00:00
4 => 'account.456'
2021-06-30 11:36:58 +00:00
];
2021-07-13 15:18:02 +00:00
$channels = Realtime :: convertChannels ( $channels , $user -> getId ());
2021-08-27 08:20:44 +00:00
$this -> assertCount ( 4 , $channels );
2021-06-30 11:36:58 +00:00
$this -> assertArrayHasKey ( 'files' , $channels );
$this -> assertArrayHasKey ( 'documents' , $channels );
$this -> assertArrayHasKey ( 'documents.789' , $channels );
2021-08-27 08:20:44 +00:00
$this -> assertArrayHasKey ( 'account' , $channels );
2021-06-30 11:36:58 +00:00
$this -> assertArrayNotHasKey ( 'account.456' , $channels );
}
2026-04-27 12:05:56 +00:00
public function testConvertChannelsUser () : void
2021-06-30 11:36:58 +00:00
{
2026-04-27 12:05:56 +00:00
$user = new Document ([
2022-08-14 10:33:36 +00:00
'$id' => ID :: custom ( '123' ),
2021-06-30 11:36:58 +00:00
'memberships' => [
[
2022-08-14 10:33:36 +00:00
'teamId' => ID :: custom ( 'abc' ),
2021-06-30 11:36:58 +00:00
'roles' => [
'administrator' ,
2026-04-27 12:05:56 +00:00
'moderator'
]
2021-06-30 11:36:58 +00:00
],
[
2022-08-14 10:33:36 +00:00
'teamId' => ID :: custom ( 'def' ),
2021-06-30 11:36:58 +00:00
'roles' => [
2026-04-27 12:05:56 +00:00
'guest'
]
]
]
2021-06-30 11:36:58 +00:00
]);
$channels = [
0 => 'files' ,
1 => 'documents' ,
2 => 'documents.789' ,
3 => 'account' ,
2026-04-27 12:05:56 +00:00
4 => 'account.456'
2021-06-30 11:36:58 +00:00
];
2021-07-13 15:18:02 +00:00
$channels = Realtime :: convertChannels ( $channels , $user -> getId ());
2021-06-30 11:36:58 +00:00
2021-08-27 08:20:44 +00:00
$this -> assertCount ( 5 , $channels );
2021-06-30 11:36:58 +00:00
$this -> assertArrayHasKey ( 'files' , $channels );
$this -> assertArrayHasKey ( 'documents' , $channels );
$this -> assertArrayHasKey ( 'documents.789' , $channels );
$this -> assertArrayHasKey ( 'account.123' , $channels );
2021-08-27 08:20:44 +00:00
$this -> assertArrayHasKey ( 'account' , $channels );
2021-06-30 11:36:58 +00:00
$this -> assertArrayNotHasKey ( 'account.456' , $channels );
}
2021-12-16 18:12:06 +00:00
2026-04-27 12:54:52 +00:00
public function testConvertChannelsRewritesAccountActionSuffixes () : void
{
// Authenticated subscriber to `account.{action}` is translated to the
// user-scoped `account.{userId}.{action}` form so events from other
// users' accounts don't leak through the literal channel.
$channels = Realtime :: convertChannels (
[ 'account.create' , 'account.update' , 'account.upsert' , 'account.delete' ],
'123' ,
);
$this -> assertArrayHasKey ( 'account.123.create' , $channels );
$this -> assertArrayHasKey ( 'account.123.update' , $channels );
$this -> assertArrayHasKey ( 'account.123.upsert' , $channels );
$this -> assertArrayHasKey ( 'account.123.delete' , $channels );
$this -> assertArrayNotHasKey ( 'account.create' , $channels );
$this -> assertArrayNotHasKey ( 'account.update' , $channels );
$this -> assertArrayNotHasKey ( 'account.upsert' , $channels );
$this -> assertArrayNotHasKey ( 'account.delete' , $channels );
// Other-user channels and unknown action-like suffixes still get stripped.
$channels = Realtime :: convertChannels (
[ 'account.other_id' , 'account.bogus' , 'account.123' , 'account.create' ],
'123' ,
);
$this -> assertArrayNotHasKey ( 'account.other_id' , $channels );
$this -> assertArrayNotHasKey ( 'account.bogus' , $channels );
$this -> assertArrayNotHasKey ( 'account.123' , $channels );
$this -> assertArrayHasKey ( 'account.123.create' , $channels );
}
public function testConvertChannelsPreservesAccountActionsForGuest () : void
{
// Guests can't scope an action filter to a userId yet, so `account.{action}`
// is preserved verbatim. fromPayload publishes the unscoped `account.{action}`
// channel for top-level user events, so the guest's stored form matches and
// delivers correctly. After the connection authenticates,
// rebindAccountChannels rewrites the literal to `account.{userId}.{action}`
// so the action filter survives the auth transition.
$channels = Realtime :: convertChannels (
[ 'account.create' , 'account.update' , 'account.upsert' , 'account.delete' , 'account' ],
'' ,
);
$this -> assertArrayHasKey ( 'account.create' , $channels );
$this -> assertArrayHasKey ( 'account.update' , $channels );
$this -> assertArrayHasKey ( 'account.upsert' , $channels );
$this -> assertArrayHasKey ( 'account.delete' , $channels );
$this -> assertArrayHasKey ( 'account' , $channels );
}
public function testRebindAccountChannelsRemapsAfterReauth () : void
{
// Reauth as a different user must remap the user-scoped channels so the
// connection no longer receives the previous user's account events.
$rebound = Realtime :: rebindAccountChannels (
[ 'account.A' , 'account.A.create' , 'account.A.update' , 'documents' , 'documents.A.something' ],
'A' ,
'B' ,
);
$this -> assertContains ( 'account.B' , $rebound );
$this -> assertContains ( 'account.B.create' , $rebound );
$this -> assertContains ( 'account.B.update' , $rebound );
$this -> assertNotContains ( 'account.A' , $rebound );
$this -> assertNotContains ( 'account.A.create' , $rebound );
$this -> assertNotContains ( 'account.A.update' , $rebound );
// Non-account channels left alone — the rewrite is precise.
$this -> assertContains ( 'documents' , $rebound );
$this -> assertContains ( 'documents.A.something' , $rebound );
}
public function testRebindAccountChannelsIsNoopForUnchangedUser () : void
{
// Same user → nothing to rewrite. Avoids unnecessary churn when the
// permissionsChanged path fires (roles change, userId is constant).
$channels = [ 'account.A' , 'account.A.create' , 'documents' ];
$this -> assertSame ( $channels , Realtime :: rebindAccountChannels ( $channels , 'A' , 'A' ));
}
public function testRebindAccountChannelsIsNoopForEmptyTarget () : void
{
// Defensive: if a caller ever passes an empty $newUserId (e.g. a
// hypothetical in-band logout), we leave channels untouched rather than
// producing malformed `account.` strings.
$channels = [ 'account.A' , 'account.A.create' , 'account.create' , 'documents' ];
$this -> assertSame ( $channels , Realtime :: rebindAccountChannels ( $channels , 'A' , '' ));
$this -> assertSame ( $channels , Realtime :: rebindAccountChannels ( $channels , '' , '' ));
}
public function testRebindAccountChannelsPromotesGuestActionFilters () : void
{
// Guest connections store `account.{action}` literally (convertChannels
// preserves the form when userId is empty). On in-band authentication,
// rebindAccountChannels promotes those literals to user-scoped form so
// the action filter survives.
$rebound = Realtime :: rebindAccountChannels (
[ 'account' , 'account.create' , 'account.update' , 'documents' ],
'' ,
'B' ,
);
$this -> assertContains ( 'account.B.create' , $rebound );
$this -> assertContains ( 'account.B.update' , $rebound );
$this -> assertNotContains ( 'account.create' , $rebound );
$this -> assertNotContains ( 'account.update' , $rebound );
// Plain `account` and unrelated channels are left alone.
$this -> assertContains ( 'account' , $rebound );
$this -> assertContains ( 'documents' , $rebound );
}
public function testRebindAccountChannelsOnlyRemapsKnownActions () : void
{
// Defensive: only suffixes in SUPPORTED_ACTIONS are rewritten, so a
// channel like `account.A.bogus` stays intact rather than being
// silently rebound.
$rebound = Realtime :: rebindAccountChannels (
[ 'account.A.bogus' , 'account.A.create' ],
'A' ,
'B' ,
);
$this -> assertContains ( 'account.A.bogus' , $rebound );
$this -> assertContains ( 'account.B.create' , $rebound );
$this -> assertNotContains ( 'account.B.bogus' , $rebound );
$this -> assertNotContains ( 'account.A.create' , $rebound );
}
public function testReauthThenPermissionsChangeThenReauthPreservesAccountAction () : void
{
// Full lifecycle, mirrors the auth + permissionsChanged handler logic in
// app/realtime.php:
// 1. user A subscribes to account.create (stored as account.A.create)
// 2. in-band reauth as B → rebound to account.B.create, userId=B
// 3. permissions-change for B → userId on connection MUST stay 'B'
// so a subsequent reauth as C still has previousUserId='B'.
// 4. reauth as C → rebound to account.C.create, userId=C
$realtime = new Realtime ();
// Step 1.
$aChannels = \array_keys ( Realtime :: convertChannels ([ 'account.create' ], 'A' ));
$this -> assertSame ([ 'account.A.create' ], $aChannels );
$realtime -> subscribe ( '1' , 1 , 'sub-1' , [ Role :: user ( ID :: custom ( 'A' )) -> toString ()], $aChannels , [], 'A' );
$this -> assertSame ( 'A' , $realtime -> connections [ 1 ][ 'userId' ]);
// Step 2: A → B.
$previousUserId = $realtime -> connections [ 1 ][ 'userId' ];
$meta = $realtime -> getSubscriptionMetadata ( 1 );
$realtime -> unsubscribe ( 1 );
foreach ( $meta as $subId => $sub ) {
$rebound = Realtime :: rebindAccountChannels ( $sub [ 'channels' ], $previousUserId , 'B' );
$realtime -> subscribe ( '1' , 1 , $subId , [ Role :: user ( ID :: custom ( 'B' )) -> toString ()], $rebound , [], 'B' );
}
$this -> assertSame ( 'B' , $realtime -> connections [ 1 ][ 'userId' ]);
$this -> assertContains ( 'account.B.create' , $realtime -> connections [ 1 ][ 'channels' ]);
// Step 3: permissions-change for B (userId stays 'B').
$previousUserId = $realtime -> connections [ 1 ][ 'userId' ];
$meta = $realtime -> getSubscriptionMetadata ( 1 );
$realtime -> unsubscribe ( 1 );
foreach ( $meta as $subId => $sub ) {
$rebound = Realtime :: rebindAccountChannels ( $sub [ 'channels' ], $previousUserId , 'B' );
$realtime -> subscribe ( '1' , 1 , $subId , [ Role :: user ( ID :: custom ( 'B' )) -> toString ()], $rebound , [], 'B' );
}
$this -> assertSame ( 'B' , $realtime -> connections [ 1 ][ 'userId' ]);
$this -> assertContains ( 'account.B.create' , $realtime -> connections [ 1 ][ 'channels' ]);
// Step 4: B → C.
$previousUserId = $realtime -> connections [ 1 ][ 'userId' ];
$meta = $realtime -> getSubscriptionMetadata ( 1 );
$realtime -> unsubscribe ( 1 );
foreach ( $meta as $subId => $sub ) {
$rebound = Realtime :: rebindAccountChannels ( $sub [ 'channels' ], $previousUserId , 'C' );
$realtime -> subscribe ( '1' , 1 , $subId , [ Role :: user ( ID :: custom ( 'C' )) -> toString ()], $rebound , [], 'C' );
}
$this -> assertSame ( 'C' , $realtime -> connections [ 1 ][ 'userId' ]);
$this -> assertContains ( 'account.C.create' , $realtime -> connections [ 1 ][ 'channels' ]);
$this -> assertNotContains ( 'account.B.create' , $realtime -> connections [ 1 ][ 'channels' ]);
$this -> assertNotContains ( 'account.A.create' , $realtime -> connections [ 1 ][ 'channels' ]);
}
public function testGuestAccountActionFilterSurvivesAuthenticationEndToEnd () : void
{
// Full lifecycle:
// 1. Guest connects, subscribes to `account.create`.
// 2. fromPayload publishes a top-level `users.B.create` event — guest
// receives it via the unscoped `account.create` broadcast channel.
// 3. Guest authenticates as B. Resubscribe goes through
// rebindAccountChannels so the same subscription is now scoped to
// `account.B.create` and only matches B's events.
$realtime = new Realtime ();
// Step 1: guest subscribes. convertChannels preserves the literal form.
$guestChannels = \array_keys ( Realtime :: convertChannels ([ 'account.create' ], '' ));
$this -> assertSame ([ 'account.create' ], $guestChannels );
$realtime -> subscribe ( '1' , 1 , 'sub-1' , [ Role :: guests () -> toString ()], $guestChannels , [], '' );
// Step 2: fromPayload publishes account.create alongside the user-scoped form.
$publish = Realtime :: fromPayload (
event : 'users.B.create' ,
payload : new Document ([ '$id' => ID :: custom ( 'B' )]),
);
$this -> assertContains ( 'account.create' , $publish [ 'channels' ]);
$this -> assertContains ( 'account.B.create' , $publish [ 'channels' ]);
// Guest receives the unscoped channel.
$event = [
'project' => '1' ,
'roles' => [ Role :: guests () -> toString ()],
'data' => [
'channels' => $publish [ 'channels' ],
'payload' => [ '$id' => 'B' ],
],
];
$this -> assertArrayHasKey ( 1 , $realtime -> getSubscribers ( $event ));
// Step 3: in-band auth promotes the guest to user 'B'.
$previousUserId = $realtime -> connections [ 1 ][ 'userId' ] ? ? '' ;
$meta = $realtime -> getSubscriptionMetadata ( 1 );
$realtime -> unsubscribe ( 1 );
foreach ( $meta as $subId => $sub ) {
$rebound = Realtime :: rebindAccountChannels ( $sub [ 'channels' ], $previousUserId , 'B' );
$realtime -> subscribe ( '1' , 1 , $subId , [ Role :: user ( ID :: custom ( 'B' )) -> toString ()], $rebound , [], 'B' );
}
// Literal channel is gone; user-scoped form is in place.
$this -> assertNotContains ( 'account.create' , $realtime -> connections [ 1 ][ 'channels' ]);
$this -> assertContains ( 'account.B.create' , $realtime -> connections [ 1 ][ 'channels' ]);
// B-scoped event delivers via the user-scoped channel.
$bEvent = [
'project' => '1' ,
'roles' => [ Role :: user ( ID :: custom ( 'B' )) -> toString ()],
'data' => [
'channels' => $publish [ 'channels' ],
'payload' => [ '$id' => 'B' ],
],
];
$this -> assertArrayHasKey ( 1 , $realtime -> getSubscribers ( $bEvent ));
}
2026-04-27 12:05:56 +00:00
public function testFromPayloadPermissions () : void
2021-12-16 18:12:06 +00:00
{
/**
* Test Collection Level Permissions
*/
$result = Realtime :: fromPayload (
2022-06-22 10:51:49 +00:00
event : 'databases.database_id.collections.collection_id.documents.document_id.create' ,
2021-12-16 18:12:06 +00:00
payload : new Document ([
2022-08-14 10:33:36 +00:00
'$id' => ID :: custom ( 'test' ),
'$collection' => ID :: custom ( 'collection' ),
2022-08-02 09:21:53 +00:00
'$permissions' => [
2022-08-19 04:04:33 +00:00
Permission :: read ( Role :: team ( '123abc' )),
Permission :: update ( Role :: team ( '123abc' )),
Permission :: delete ( Role :: team ( '123abc' )),
2022-08-02 09:21:53 +00:00
],
2021-12-16 18:12:06 +00:00
]),
2022-08-13 14:55:15 +00:00
database : new Document ([
2022-08-14 10:33:36 +00:00
'$id' => ID :: custom ( 'database' ),
2022-08-13 14:55:15 +00:00
]),
2021-12-16 18:12:06 +00:00
collection : new Document ([
2022-08-14 10:33:36 +00:00
'$id' => ID :: custom ( 'collection' ),
2022-08-02 09:21:53 +00:00
'$permissions' => [
2022-08-14 05:21:11 +00:00
Permission :: read ( Role :: any ()),
Permission :: update ( Role :: any ()),
Permission :: delete ( Role :: any ()),
2022-08-02 09:21:53 +00:00
],
2021-12-16 18:12:06 +00:00
])
);
2022-08-19 04:04:33 +00:00
$this -> assertContains ( Role :: any () -> toString (), $result [ 'roles' ]);
$this -> assertNotContains ( Role :: team ( '123abc' ) -> toString (), $result [ 'roles' ]);
2021-12-16 18:12:06 +00:00
/**
* Test Document Level Permissions
*/
$result = Realtime :: fromPayload (
2022-06-22 10:51:49 +00:00
event : 'databases.database_id.collections.collection_id.documents.document_id.create' ,
2021-12-16 18:12:06 +00:00
payload : new Document ([
2022-08-14 10:33:36 +00:00
'$id' => ID :: custom ( 'test' ),
'$collection' => ID :: custom ( 'collection' ),
2022-08-02 09:21:53 +00:00
'$permissions' => [
2022-08-14 05:21:11 +00:00
Permission :: read ( Role :: any ()),
Permission :: update ( Role :: any ()),
Permission :: delete ( Role :: any ()),
2022-08-02 09:21:53 +00:00
],
2021-12-16 18:12:06 +00:00
]),
2022-08-13 14:55:15 +00:00
database : new Document ([
2022-08-14 10:33:36 +00:00
'$id' => ID :: custom ( 'database' ),
2022-08-13 14:55:15 +00:00
]),
2021-12-16 18:12:06 +00:00
collection : new Document ([
2022-08-14 10:33:36 +00:00
'$id' => ID :: custom ( 'collection' ),
2022-08-02 09:21:53 +00:00
'$permissions' => [
2022-08-19 04:04:33 +00:00
Permission :: read ( Role :: team ( '123abc' )),
Permission :: update ( Role :: team ( '123abc' )),
Permission :: delete ( Role :: team ( '123abc' )),
2022-08-02 09:21:53 +00:00
],
'documentSecurity' => true ,
2021-12-16 18:12:06 +00:00
])
);
2022-08-19 04:04:33 +00:00
$this -> assertContains ( Role :: any () -> toString (), $result [ 'roles' ]);
$this -> assertContains ( Role :: team ( '123abc' ) -> toString (), $result [ 'roles' ]);
2021-12-16 18:12:06 +00:00
}
2022-04-18 16:21:45 +00:00
2026-04-27 12:05:56 +00:00
public function testFromPayloadBucketLevelPermissions () : void
2022-04-18 16:21:45 +00:00
{
/**
2022-08-13 14:55:15 +00:00
* Test Bucket Level Permissions
2022-04-18 16:21:45 +00:00
*/
$result = Realtime :: fromPayload (
event : 'buckets.bucket_id.files.file_id.create' ,
payload : new Document ([
2022-08-14 10:33:36 +00:00
'$id' => ID :: custom ( 'test' ),
'$collection' => ID :: custom ( 'bucket' ),
2022-08-02 09:21:53 +00:00
'$permissions' => [
2022-08-19 04:04:33 +00:00
Permission :: read ( Role :: team ( '123abc' )),
Permission :: update ( Role :: team ( '123abc' )),
Permission :: delete ( Role :: team ( '123abc' )),
2022-08-02 09:21:53 +00:00
],
2022-04-18 16:21:45 +00:00
]),
bucket : new Document ([
2022-08-14 10:33:36 +00:00
'$id' => ID :: custom ( 'bucket' ),
2022-08-02 09:21:53 +00:00
'$permissions' => [
2022-08-14 05:21:11 +00:00
Permission :: read ( Role :: any ()),
Permission :: update ( Role :: any ()),
Permission :: delete ( Role :: any ()),
2022-08-02 09:21:53 +00:00
],
2022-04-18 16:21:45 +00:00
])
);
2022-08-19 04:04:33 +00:00
$this -> assertContains ( Role :: any () -> toString (), $result [ 'roles' ]);
$this -> assertNotContains ( Role :: team ( '123abc' ) -> toString (), $result [ 'roles' ]);
2022-04-18 16:21:45 +00:00
/**
2022-08-13 14:55:15 +00:00
* Test File Level Permissions
2022-04-18 16:21:45 +00:00
*/
$result = Realtime :: fromPayload (
event : 'buckets.bucket_id.files.file_id.create' ,
payload : new Document ([
2022-08-14 10:33:36 +00:00
'$id' => ID :: custom ( 'test' ),
'$collection' => ID :: custom ( 'bucket' ),
2022-08-02 09:21:53 +00:00
'$permissions' => [
2022-08-14 05:21:11 +00:00
Permission :: read ( Role :: any ()),
Permission :: update ( Role :: any ()),
Permission :: delete ( Role :: any ()),
2022-08-02 09:21:53 +00:00
],
2022-04-18 16:21:45 +00:00
]),
bucket : new Document ([
2022-08-14 10:33:36 +00:00
'$id' => ID :: custom ( 'bucket' ),
2022-08-02 09:21:53 +00:00
'$permissions' => [
2022-08-19 04:04:33 +00:00
Permission :: read ( Role :: team ( '123abc' )),
Permission :: update ( Role :: team ( '123abc' )),
Permission :: delete ( Role :: team ( '123abc' )),
2022-08-02 09:21:53 +00:00
],
2026-04-27 12:05:56 +00:00
'fileSecurity' => true
2022-04-18 16:21:45 +00:00
])
);
2022-08-19 04:04:33 +00:00
$this -> assertContains ( Role :: any () -> toString (), $result [ 'roles' ]);
$this -> assertContains ( Role :: team ( '123abc' ) -> toString (), $result [ 'roles' ]);
2022-04-18 16:21:45 +00:00
}
2026-04-27 12:05:56 +00:00
public function testFromPayloadEmitsActionSuffixedChannels () : void
2026-04-27 08:00:18 +00:00
{
2026-04-27 10:16:02 +00:00
$result = Realtime :: fromPayload (
event : 'databases.database_id.collections.collection_id.documents.document_id.create' ,
payload : new Document ([
'$id' => ID :: custom ( 'document_id' ),
'$collection' => ID :: custom ( 'collection_id' ),
'$collectionId' => 'collection_id' ,
'$permissions' => [ Permission :: read ( Role :: any ())],
]),
database : new Document ([ '$id' => ID :: custom ( 'database_id' )]),
collection : new Document ([
'$id' => ID :: custom ( 'collection_id' ),
'$permissions' => [ Permission :: read ( Role :: any ())],
])
2026-04-27 08:00:18 +00:00
);
2026-04-27 10:16:02 +00:00
// Base channels remain.
$this -> assertContains ( 'documents' , $result [ 'channels' ]);
$this -> assertContains ( 'databases.database_id.collections.collection_id.documents' , $result [ 'channels' ]);
$this -> assertContains ( 'databases.database_id.collections.collection_id.documents.document_id' , $result [ 'channels' ]);
2026-04-27 08:00:18 +00:00
2026-04-27 10:16:02 +00:00
// Action-suffixed variants are appended for every base channel.
$this -> assertContains ( 'documents.create' , $result [ 'channels' ]);
$this -> assertContains ( 'databases.database_id.collections.collection_id.documents.create' , $result [ 'channels' ]);
$this -> assertContains ( 'databases.database_id.collections.collection_id.documents.document_id.create' , $result [ 'channels' ]);
2026-04-27 08:00:18 +00:00
2026-04-27 10:16:02 +00:00
// No mismatched action suffixes leak in.
$this -> assertNotContains ( 'documents.update' , $result [ 'channels' ]);
$this -> assertNotContains ( 'documents.delete' , $result [ 'channels' ]);
2026-04-27 08:00:18 +00:00
}
2026-04-27 12:05:56 +00:00
public function testFromPayloadEmitsActionSuffixForEveryAction () : void
2026-04-27 08:00:18 +00:00
{
2026-04-27 10:16:02 +00:00
foreach ([ 'create' , 'update' , 'upsert' , 'delete' ] as $action ) {
$result = Realtime :: fromPayload (
event : " databases.database_id.collections.collection_id.documents.document_id. { $action } " ,
payload : new Document ([
'$id' => ID :: custom ( 'document_id' ),
'$collection' => ID :: custom ( 'collection_id' ),
'$collectionId' => 'collection_id' ,
'$permissions' => [ Permission :: read ( Role :: any ())],
]),
database : new Document ([ '$id' => ID :: custom ( 'database_id' )]),
collection : new Document ([
'$id' => ID :: custom ( 'collection_id' ),
'$permissions' => [ Permission :: read ( Role :: any ())],
])
);
$this -> assertContains ( " documents. { $action } " , $result [ 'channels' ], " documents. { $action } missing " );
$this -> assertContains (
" databases.database_id.collections.collection_id.documents.document_id. { $action } " ,
$result [ 'channels' ],
" specific-doc { $action } channel missing "
);
}
2026-04-27 08:00:18 +00:00
}
2026-04-27 12:05:56 +00:00
public function testFromPayloadDoesNotSuffixWhenNoAction () : void
2026-04-27 07:22:52 +00:00
{
2026-04-27 10:16:02 +00:00
// Synthetic event without an action segment: e.g. an attribute event whose
// last segment is not a known action and whose second-to-last segment is
// also not a known action.
$result = Realtime :: fromPayload (
event : 'buckets.bucket_id.files.file_id.update' ,
payload : new Document ([
'$id' => ID :: custom ( 'file_id' ),
'bucketId' => 'bucket_id' ,
'$permissions' => [ Permission :: read ( Role :: any ())],
]),
bucket : new Document ([
'$id' => ID :: custom ( 'bucket_id' ),
'$permissions' => [ Permission :: read ( Role :: any ())],
])
2026-04-27 07:45:04 +00:00
);
2026-04-27 07:22:52 +00:00
2026-04-27 10:16:02 +00:00
// Action-suffixed variants for the file event.
$this -> assertContains ( 'files.update' , $result [ 'channels' ]);
$this -> assertContains ( 'buckets.bucket_id.files.update' , $result [ 'channels' ]);
$this -> assertContains ( 'buckets.bucket_id.files.file_id.update' , $result [ 'channels' ]);
2026-04-27 07:22:52 +00:00
2026-04-27 10:16:02 +00:00
// Base channels remain.
$this -> assertContains ( 'files' , $result [ 'channels' ]);
$this -> assertContains ( 'buckets.bucket_id.files' , $result [ 'channels' ]);
$this -> assertContains ( 'buckets.bucket_id.files.file_id' , $result [ 'channels' ]);
2026-04-27 07:22:52 +00:00
}
2026-04-27 12:05:56 +00:00
public function testFromPayloadDoesNotSuffixAdminChannels () : void
2026-04-27 07:22:52 +00:00
{
2026-04-27 10:16:02 +00:00
// Function execution event emits resource-leaf channels (executions / functions)
// alongside admin channels (console / projects.X). Admin channels must NOT
// get an action suffix — only the resource-leaf channels do.
$result = Realtime :: fromPayload (
event : 'functions.function_id.executions.execution_id.create' ,
payload : new Document ([
'$id' => ID :: custom ( 'execution_id' ),
'functionId' => 'function_id' ,
'$read' => [ Role :: any () -> toString ()],
'$permissions' => [ Permission :: read ( Role :: any ())],
]),
project : new Document ([
'$id' => ID :: custom ( 'project_id' ),
'teamId' => '123abc' ,
])
2026-04-27 07:22:52 +00:00
);
2026-04-27 10:16:02 +00:00
// Resource-leaf channels are suffixed.
$this -> assertContains ( 'executions' , $result [ 'channels' ]);
$this -> assertContains ( 'executions.create' , $result [ 'channels' ]);
$this -> assertContains ( 'executions.execution_id' , $result [ 'channels' ]);
$this -> assertContains ( 'executions.execution_id.create' , $result [ 'channels' ]);
$this -> assertContains ( 'functions.function_id' , $result [ 'channels' ]);
$this -> assertContains ( 'functions.function_id.create' , $result [ 'channels' ]);
// Admin channels are NOT suffixed.
$this -> assertContains ( 'console' , $result [ 'channels' ]);
$this -> assertNotContains ( 'console.create' , $result [ 'channels' ]);
$this -> assertContains ( 'projects.project_id' , $result [ 'channels' ]);
$this -> assertNotContains ( 'projects.project_id.create' , $result [ 'channels' ]);
2026-04-27 13:16:04 +00:00
// The bare `functions` channel is never emitted by fromPayload (only
// `functions.{functionId}` is). The per-function action variant
// (`functions.{functionId}.create`) is the supported subscription
// form — bare `functions.create` would be a silent no-op and must
// therefore NOT appear in the published channel set either.
$this -> assertNotContains ( 'functions' , $result [ 'channels' ]);
$this -> assertNotContains ( 'functions.create' , $result [ 'channels' ]);
2026-04-27 07:22:52 +00:00
}
2026-04-27 12:05:56 +00:00
public function testFromPayloadHandlesAttributeTrailingActionEvents () : void
2026-04-27 10:36:00 +00:00
{
// `users.[userId].update.{attr}` (e.g. .email, .prefs, .name) — action is the
// second-to-last segment, not the last one. The suffix must still be `.update`.
$userResult = Realtime :: fromPayload (
event : 'users.user_id.update.email' ,
payload : new Document ([ '$id' => ID :: custom ( 'user_id' )])
);
$this -> assertContains ( 'account' , $userResult [ 'channels' ]);
$this -> assertContains ( 'account.user_id' , $userResult [ 'channels' ]);
$this -> assertContains ( 'account.update' , $userResult [ 'channels' ]);
$this -> assertContains ( 'account.user_id.update' , $userResult [ 'channels' ]);
// The attribute name must NOT leak into the channel namespace.
$this -> assertNotContains ( 'account.email' , $userResult [ 'channels' ]);
$this -> assertNotContains ( 'account.user_id.email' , $userResult [ 'channels' ]);
// `teams.[teamId].update.prefs` — same shape at the team level.
$teamResult = Realtime :: fromPayload (
event : 'teams.team_id.update.prefs' ,
payload : new Document ([ '$id' => ID :: custom ( 'team_id' )])
);
$this -> assertContains ( 'teams' , $teamResult [ 'channels' ]);
$this -> assertContains ( 'teams.team_id' , $teamResult [ 'channels' ]);
$this -> assertContains ( 'teams.update' , $teamResult [ 'channels' ]);
$this -> assertContains ( 'teams.team_id.update' , $teamResult [ 'channels' ]);
$this -> assertNotContains ( 'teams.prefs' , $teamResult [ 'channels' ]);
$this -> assertNotContains ( 'teams.team_id.prefs' , $teamResult [ 'channels' ]);
// `teams.[teamId].memberships.[membershipId].update.{attr}` — same again, deeper.
$membershipResult = Realtime :: fromPayload (
event : 'teams.team_id.memberships.membership_id.update.status' ,
payload : new Document ([ '$id' => ID :: custom ( 'membership_id' )])
);
$this -> assertContains ( 'memberships' , $membershipResult [ 'channels' ]);
$this -> assertContains ( 'memberships.membership_id' , $membershipResult [ 'channels' ]);
$this -> assertContains ( 'memberships.update' , $membershipResult [ 'channels' ]);
$this -> assertContains ( 'memberships.membership_id.update' , $membershipResult [ 'channels' ]);
$this -> assertNotContains ( 'memberships.status' , $membershipResult [ 'channels' ]);
$this -> assertNotContains ( 'memberships.membership_id.status' , $membershipResult [ 'channels' ]);
}
2026-04-27 11:56:27 +00:00
public function testFromPayloadDoesNotSuffixAccountForNestedUserEvents () : void
2026-04-27 11:10:15 +00:00
{
// Nested user events (challenges/sessions/recovery/verification) emit only
// user-level account channels in fromPayload. The trailing action belongs to
// the nested resource, NOT to the user account. A subscriber to
// `account.create` must not receive `users.U.challenges.C.create` or
// `users.U.sessions.S.delete` events — that would silently leak unrelated
// MFA / session traffic into account-level filters.
foreach ([ 'challenges' , 'sessions' , 'recovery' , 'verification' ] as $sub ) {
foreach ([ 'create' , 'update' , 'delete' ] as $action ) {
$result = Realtime :: fromPayload (
event : " users.user_id. { $sub } .sub_id. { $action } " ,
payload : new Document ([ '$id' => ID :: custom ( 'sub_id' )])
);
$this -> assertContains ( 'account' , $result [ 'channels' ], " { $sub } . { $action } should still emit base account channel " );
$this -> assertContains ( 'account.user_id' , $result [ 'channels' ], " { $sub } . { $action } should still emit user-scoped account channel " );
$this -> assertNotContains ( " account. { $action } " , $result [ 'channels' ], " { $sub } . { $action } must NOT leak action suffix onto account channel " );
$this -> assertNotContains ( " account.user_id. { $action } " , $result [ 'channels' ], " { $sub } . { $action } must NOT leak action suffix onto user-scoped account channel " );
}
}
// Top-level user events SHOULD still suffix — guard against an over-eager fix
// that suppresses the suffix for legitimate account-level CRUD.
$createResult = Realtime :: fromPayload (
event : 'users.user_id.create' ,
payload : new Document ([ '$id' => ID :: custom ( 'user_id' )])
);
$this -> assertContains ( 'account.create' , $createResult [ 'channels' ]);
$this -> assertContains ( 'account.user_id.create' , $createResult [ 'channels' ]);
$updateResult = Realtime :: fromPayload (
event : 'users.user_id.update.email' ,
payload : new Document ([ '$id' => ID :: custom ( 'user_id' )])
);
$this -> assertContains ( 'account.update' , $updateResult [ 'channels' ]);
$this -> assertContains ( 'account.user_id.update' , $updateResult [ 'channels' ]);
}
2026-04-27 12:05:56 +00:00
public function testActionSuffixDeliversOnlyMatchingActionEndToEnd () : void
2026-04-27 07:45:04 +00:00
{
$realtime = new Realtime ();
2026-04-27 10:16:02 +00:00
// Subscriber A scopes to creates; Subscriber B scopes to deletes.
$realtime -> subscribe ( '1' , 1 , 'sub-create' , [ Role :: any () -> toString ()], [ 'documents.create' ]);
$realtime -> subscribe ( '1' , 2 , 'sub-delete' , [ Role :: any () -> toString ()], [ 'documents.delete' ]);
2026-04-27 07:45:04 +00:00
2026-04-27 10:16:02 +00:00
// Simulate what fromPayload would publish for a create event.
$createEvent = [
2026-04-27 07:45:04 +00:00
'project' => '1' ,
'roles' => [ Role :: any () -> toString ()],
'data' => [
2026-04-27 10:16:02 +00:00
'channels' => [ 'documents' , 'documents.create' ],
2026-04-27 07:45:04 +00:00
'payload' => [ '$id' => 'doc' ],
],
];
2026-04-27 10:16:02 +00:00
$createReceivers = $realtime -> getSubscribers ( $createEvent );
$this -> assertArrayHasKey ( 1 , $createReceivers );
$this -> assertArrayNotHasKey ( 2 , $createReceivers );
2026-04-27 07:45:04 +00:00
2026-04-27 10:16:02 +00:00
// Delete event.
$deleteEvent = [
2026-04-27 07:22:52 +00:00
'project' => '1' ,
'roles' => [ Role :: any () -> toString ()],
'data' => [
2026-04-27 10:16:02 +00:00
'channels' => [ 'documents' , 'documents.delete' ],
2026-04-27 07:22:52 +00:00
'payload' => [ '$id' => 'doc' ],
],
];
2026-04-27 10:16:02 +00:00
$deleteReceivers = $realtime -> getSubscribers ( $deleteEvent );
$this -> assertArrayHasKey ( 2 , $deleteReceivers );
$this -> assertArrayNotHasKey ( 1 , $deleteReceivers );
2026-04-27 07:22:52 +00:00
}
2026-04-27 12:05:56 +00:00
public function testPlainChannelStillReceivesAllActionsEndToEnd () : void
2026-04-27 07:22:52 +00:00
{
$realtime = new Realtime ();
2026-04-27 10:16:02 +00:00
$realtime -> subscribe ( '1' , 1 , 'sub-all' , [ Role :: any () -> toString ()], [ 'documents' ]);
2026-04-27 07:22:52 +00:00
2026-04-27 10:16:02 +00:00
foreach ([ 'create' , 'update' , 'upsert' , 'delete' ] as $action ) {
$event = [
'project' => '1' ,
'roles' => [ Role :: any () -> toString ()],
'data' => [
'channels' => [ 'documents' , " documents. { $action } " ],
'payload' => [ '$id' => 'doc' ],
],
];
$this -> assertArrayHasKey ( 1 , $realtime -> getSubscribers ( $event ), " plain `documents` should match { $action } event " );
}
2026-04-27 07:22:52 +00:00
}
2026-04-27 10:36:00 +00:00
}