From 2703e2aa9097e24dcb1f18c552f5d2b86678252a Mon Sep 17 00:00:00 2001 From: "PUBNUB\\jakub.grzesiowski" Date: Mon, 14 Sep 2026 16:21:53 +0200 Subject: [PATCH 1/8] Initial implementation with generic entities and relationships --- .pubnub.yml | 32 ++ examples/DataSync.php | 305 ++++++++++++ src/PubNub/Callbacks/SubscribeCallback.php | 25 +- src/PubNub/DataSync.php | 209 ++++++++ src/PubNub/Endpoints/Access/GrantToken.php | 138 ++++++ .../DataSync/Channel/CreateChannel.php | 122 +++++ .../DataSync/Channel/DeleteChannel.php | 56 +++ .../Endpoints/DataSync/Channel/GetChannel.php | 53 ++ .../DataSync/Channel/GetChannels.php | 104 ++++ .../Endpoints/DataSync/Channel/SetChannel.php | 82 ++++ .../DataSync/Channel/UpdateChannel.php | 67 +++ .../DataSync/DataSyncCollectionEndpoint.php | 149 ++++++ .../Endpoints/DataSync/DataSyncEndpoint.php | 142 ++++++ .../DataSync/Entity/CreateEntity.php | 125 +++++ .../DataSync/Entity/DeleteEntity.php | 56 +++ .../Endpoints/DataSync/Entity/GetEntities.php | 105 ++++ .../Endpoints/DataSync/Entity/GetEntity.php | 53 ++ .../Endpoints/DataSync/Entity/SetEntity.php | 83 ++++ .../DataSync/Entity/UpdateEntity.php | 67 +++ .../DataSync/Membership/CreateMembership.php | 123 +++++ .../DataSync/Membership/DeleteMembership.php | 56 +++ .../DataSync/Membership/GetMembership.php | 53 ++ .../DataSync/Membership/GetMemberships.php | 99 ++++ .../DataSync/Membership/SetMembership.php | 82 ++++ .../DataSync/Membership/UpdateMembership.php | 67 +++ .../Relationship/CreateRelationship.php | 140 ++++++ .../Relationship/DeleteRelationship.php | 56 +++ .../DataSync/Relationship/GetRelationship.php | 53 ++ .../Relationship/GetRelationships.php | 120 +++++ .../DataSync/Relationship/SetRelationship.php | 82 ++++ .../Relationship/UpdateRelationship.php | 67 +++ .../Endpoints/DataSync/Traits/HasIfMatch.php | 24 + .../DataSync/Traits/HasJsonPatch.php | 83 ++++ .../DataSync/Traits/HasPayloadAndStatus.php | 53 ++ .../Endpoints/DataSync/User/CreateUser.php | 122 +++++ .../Endpoints/DataSync/User/DeleteUser.php | 56 +++ .../Endpoints/DataSync/User/GetUser.php | 53 ++ .../Endpoints/DataSync/User/GetUsers.php | 104 ++++ .../Endpoints/DataSync/User/SetUser.php | 82 ++++ .../Endpoints/DataSync/User/UpdateUser.php | 67 +++ src/PubNub/Endpoints/Endpoint.php | 37 +- src/PubNub/Enums/PNHttpMethod.php | 12 +- src/PubNub/Enums/PNOperationType.php | 37 ++ src/PubNub/Managers/ListenerManager.php | 16 +- src/PubNub/Managers/SubscriptionManager.php | 17 + .../PNAccessManagerTokenResult.php | 87 +++- .../PNDataSyncProjectionScope.php | 166 +++++++ .../AccessManager/PNDataSyncProjections.php | 72 +++ .../DataSync/PNDataSyncChannelResult.php | 12 + .../DataSync/PNDataSyncChannelsResult.php | 10 + .../DataSync/PNDataSyncDeleteResult.php | 30 ++ .../DataSync/PNDataSyncEntitiesResult.php | 69 +++ .../Consumer/DataSync/PNDataSyncEntity.php | 147 ++++++ .../DataSync/PNDataSyncEntityResult.php | 49 ++ .../DataSync/PNDataSyncEventResult.php | 274 +++++++++++ .../DataSync/PNDataSyncMembership.php | 157 ++++++ .../DataSync/PNDataSyncMembershipResult.php | 49 ++ .../DataSync/PNDataSyncMembershipsResult.php | 69 +++ .../Consumer/DataSync/PNDataSyncPage.php | 67 +++ .../Consumer/DataSync/PNDataSyncPatch.php | 114 +++++ .../DataSync/PNDataSyncPatchOperation.php | 77 +++ .../DataSync/PNDataSyncRelationship.php | 151 ++++++ .../DataSync/PNDataSyncRelationshipResult.php | 49 ++ .../PNDataSyncRelationshipsResult.php | 69 +++ .../DataSync/PNDataSyncUserResult.php | 12 + .../DataSync/PNDataSyncUsersResult.php | 10 + .../Consumer/DataSync/PNDataSyncValue.php | 61 +++ src/PubNub/Models/Server/MessageType.php | 1 + src/PubNub/PubNub.php | 10 + tests/bootstrap.php | 6 + .../dataSync/DataSyncGrantTokenTest.php | 113 +++++ .../dataSync/DataSyncRequestTest.php | 459 ++++++++++++++++++ .../dataSync/DataSyncEndpointTest.php | 386 +++++++++++++++ .../dataSync/DataSyncLifecycleTest.php | 255 ++++++++++ .../unit/dataSync/DataSyncEventResultTest.php | 141 ++++++ tests/unit/dataSync/DataSyncPatchTest.php | 64 +++ .../unit/dataSync/DataSyncTokenParseTest.php | 206 ++++++++ .../unit/dataSync/DataSyncValidationTest.php | 332 +++++++++++++ 78 files changed, 7478 insertions(+), 30 deletions(-) create mode 100644 examples/DataSync.php create mode 100644 src/PubNub/DataSync.php create mode 100644 src/PubNub/Endpoints/DataSync/Channel/CreateChannel.php create mode 100644 src/PubNub/Endpoints/DataSync/Channel/DeleteChannel.php create mode 100644 src/PubNub/Endpoints/DataSync/Channel/GetChannel.php create mode 100644 src/PubNub/Endpoints/DataSync/Channel/GetChannels.php create mode 100644 src/PubNub/Endpoints/DataSync/Channel/SetChannel.php create mode 100644 src/PubNub/Endpoints/DataSync/Channel/UpdateChannel.php create mode 100644 src/PubNub/Endpoints/DataSync/DataSyncCollectionEndpoint.php create mode 100644 src/PubNub/Endpoints/DataSync/DataSyncEndpoint.php create mode 100644 src/PubNub/Endpoints/DataSync/Entity/CreateEntity.php create mode 100644 src/PubNub/Endpoints/DataSync/Entity/DeleteEntity.php create mode 100644 src/PubNub/Endpoints/DataSync/Entity/GetEntities.php create mode 100644 src/PubNub/Endpoints/DataSync/Entity/GetEntity.php create mode 100644 src/PubNub/Endpoints/DataSync/Entity/SetEntity.php create mode 100644 src/PubNub/Endpoints/DataSync/Entity/UpdateEntity.php create mode 100644 src/PubNub/Endpoints/DataSync/Membership/CreateMembership.php create mode 100644 src/PubNub/Endpoints/DataSync/Membership/DeleteMembership.php create mode 100644 src/PubNub/Endpoints/DataSync/Membership/GetMembership.php create mode 100644 src/PubNub/Endpoints/DataSync/Membership/GetMemberships.php create mode 100644 src/PubNub/Endpoints/DataSync/Membership/SetMembership.php create mode 100644 src/PubNub/Endpoints/DataSync/Membership/UpdateMembership.php create mode 100644 src/PubNub/Endpoints/DataSync/Relationship/CreateRelationship.php create mode 100644 src/PubNub/Endpoints/DataSync/Relationship/DeleteRelationship.php create mode 100644 src/PubNub/Endpoints/DataSync/Relationship/GetRelationship.php create mode 100644 src/PubNub/Endpoints/DataSync/Relationship/GetRelationships.php create mode 100644 src/PubNub/Endpoints/DataSync/Relationship/SetRelationship.php create mode 100644 src/PubNub/Endpoints/DataSync/Relationship/UpdateRelationship.php create mode 100644 src/PubNub/Endpoints/DataSync/Traits/HasIfMatch.php create mode 100644 src/PubNub/Endpoints/DataSync/Traits/HasJsonPatch.php create mode 100644 src/PubNub/Endpoints/DataSync/Traits/HasPayloadAndStatus.php create mode 100644 src/PubNub/Endpoints/DataSync/User/CreateUser.php create mode 100644 src/PubNub/Endpoints/DataSync/User/DeleteUser.php create mode 100644 src/PubNub/Endpoints/DataSync/User/GetUser.php create mode 100644 src/PubNub/Endpoints/DataSync/User/GetUsers.php create mode 100644 src/PubNub/Endpoints/DataSync/User/SetUser.php create mode 100644 src/PubNub/Endpoints/DataSync/User/UpdateUser.php create mode 100644 src/PubNub/Models/Consumer/AccessManager/PNDataSyncProjectionScope.php create mode 100644 src/PubNub/Models/Consumer/AccessManager/PNDataSyncProjections.php create mode 100644 src/PubNub/Models/Consumer/DataSync/PNDataSyncChannelResult.php create mode 100644 src/PubNub/Models/Consumer/DataSync/PNDataSyncChannelsResult.php create mode 100644 src/PubNub/Models/Consumer/DataSync/PNDataSyncDeleteResult.php create mode 100644 src/PubNub/Models/Consumer/DataSync/PNDataSyncEntitiesResult.php create mode 100644 src/PubNub/Models/Consumer/DataSync/PNDataSyncEntity.php create mode 100644 src/PubNub/Models/Consumer/DataSync/PNDataSyncEntityResult.php create mode 100644 src/PubNub/Models/Consumer/DataSync/PNDataSyncEventResult.php create mode 100644 src/PubNub/Models/Consumer/DataSync/PNDataSyncMembership.php create mode 100644 src/PubNub/Models/Consumer/DataSync/PNDataSyncMembershipResult.php create mode 100644 src/PubNub/Models/Consumer/DataSync/PNDataSyncMembershipsResult.php create mode 100644 src/PubNub/Models/Consumer/DataSync/PNDataSyncPage.php create mode 100644 src/PubNub/Models/Consumer/DataSync/PNDataSyncPatch.php create mode 100644 src/PubNub/Models/Consumer/DataSync/PNDataSyncPatchOperation.php create mode 100644 src/PubNub/Models/Consumer/DataSync/PNDataSyncRelationship.php create mode 100644 src/PubNub/Models/Consumer/DataSync/PNDataSyncRelationshipResult.php create mode 100644 src/PubNub/Models/Consumer/DataSync/PNDataSyncRelationshipsResult.php create mode 100644 src/PubNub/Models/Consumer/DataSync/PNDataSyncUserResult.php create mode 100644 src/PubNub/Models/Consumer/DataSync/PNDataSyncUsersResult.php create mode 100644 src/PubNub/Models/Consumer/DataSync/PNDataSyncValue.php create mode 100644 tests/functional/dataSync/DataSyncGrantTokenTest.php create mode 100644 tests/functional/dataSync/DataSyncRequestTest.php create mode 100644 tests/integrational/dataSync/DataSyncEndpointTest.php create mode 100644 tests/integrational/dataSync/DataSyncLifecycleTest.php create mode 100644 tests/unit/dataSync/DataSyncEventResultTest.php create mode 100644 tests/unit/dataSync/DataSyncPatchTest.php create mode 100644 tests/unit/dataSync/DataSyncTokenParseTest.php create mode 100644 tests/unit/dataSync/DataSyncValidationTest.php diff --git a/.pubnub.yml b/.pubnub.yml index 73282134..a828cb4c 100644 --- a/.pubnub.yml +++ b/.pubnub.yml @@ -314,6 +314,38 @@ features: - ACCESS-REVOKE-TOKEN - ACCESS-PARSE-TOKEN - ACCESS-SET-TOKEN + data-sync: + - DATA-SYNC-CREATE-ENTITY + - DATA-SYNC-GET-ENTITY + - DATA-SYNC-GET-ENTITIES + - DATA-SYNC-SET-ENTITY + - DATA-SYNC-UPDATE-ENTITY + - DATA-SYNC-DELETE-ENTITY + - DATA-SYNC-CREATE-RELATIONSHIP + - DATA-SYNC-GET-RELATIONSHIP + - DATA-SYNC-GET-RELATIONSHIPS + - DATA-SYNC-SET-RELATIONSHIP + - DATA-SYNC-UPDATE-RELATIONSHIP + - DATA-SYNC-DELETE-RELATIONSHIP + - DATA-SYNC-CREATE-USER + - DATA-SYNC-GET-USER + - DATA-SYNC-GET-USERS + - DATA-SYNC-SET-USER + - DATA-SYNC-UPDATE-USER + - DATA-SYNC-DELETE-USER + - DATA-SYNC-CREATE-CHANNEL + - DATA-SYNC-GET-CHANNEL + - DATA-SYNC-GET-CHANNELS + - DATA-SYNC-SET-CHANNEL + - DATA-SYNC-UPDATE-CHANNEL + - DATA-SYNC-DELETE-CHANNEL + - DATA-SYNC-CREATE-MEMBERSHIP + - DATA-SYNC-GET-MEMBERSHIP + - DATA-SYNC-GET-MEMBERSHIPS + - DATA-SYNC-SET-MEMBERSHIP + - DATA-SYNC-UPDATE-MEMBERSHIP + - DATA-SYNC-DELETE-MEMBERSHIP + - DATA-SYNC-EVENTS channel-groups: - CHANNEL-GROUPS-ADD-CHANNELS - CHANNEL-GROUPS-REMOVE-CHANNELS diff --git a/examples/DataSync.php b/examples/DataSync.php new file mode 100644 index 00000000..dfd6b745 --- /dev/null +++ b/examples/DataSync.php @@ -0,0 +1,305 @@ +setSubscribeKey($subscribeKey); +$config->setPublishKey($publishKey); +$config->setUserId("php-datasync-sample-" . time()); + +$pubnub = new PubNub($config); +// snippet.end + +$vehicleId = 'vehicle_' . time(); +$userId = 'user_' . time(); +$channelId = 'channel_' . time(); + +// snippet.create_entity +$created = $pubnub->dataSync() + ->createEntity() + ->entityId($vehicleId) + ->entityClass($entityClass) + ->entityClassVersion(1) + ->status('active') + ->payload([ + 'make' => 'Toyota', + 'model' => 'Camry', + 'year' => 2024, + ]) + ->sync(); + +printf("Created %s with ETag %s\n", $created->getId(), $created->getETag()); +// snippet.end + +// snippet.get_entity +$entity = $pubnub->dataSync() + ->getEntity() + ->entityId($vehicleId) + ->sync(); + +printf("Status: %s, make: %s\n", $entity->getData()->getStatus(), $entity->getData()->getPayload()['make']); +// snippet.end + +// snippet.update_entity +// A patch changes only the fields it names. Top-level fields are addressed directly, while the +// user-defined payload lives under /payload. +$patch = (new PNDataSyncPatch()) + ->replace('/status', 'inactive') + ->add('/payload/color', 'blue'); + +$updated = $pubnub->dataSync() + ->updateEntity() + ->entityId($vehicleId) + ->ifMatchesETag($entity->getETag()) + ->patch($patch) + ->sync(); + +printf("Status is now %s\n", $updated->getData()->getStatus()); +// snippet.end + +// snippet.set_entity +// Unlike a patch, a set replaces the whole record; anything left out is cleared. +$replaced = $pubnub->dataSync() + ->setEntity() + ->entityId($vehicleId) + ->entityClassVersion(1) + ->status('active') + ->payload([ + 'make' => 'Toyota', + 'model' => 'Corolla', + 'year' => 2025, + ]) + ->sync(); + +printf("Replaced, new ETag %s\n", $replaced->getETag()); +// snippet.end + +// snippet.handle_stale_etag +try { + $pubnub->dataSync() + ->updateEntity() + ->entityId($vehicleId) + ->ifMatchesETag($entity->getETag()) + ->patch((new PNDataSyncPatch())->replace('/status', 'archived')) + ->sync(); +} catch (PubNubServerException $exception) { + if ($exception->getStatusCode() === 412) { + echo "Someone else changed the record first; re-read it and retry.\n"; + } else { + throw $exception; + } +} +// snippet.end + +// snippet.list_entities +$cursor = null; + +do { + $page = $pubnub->dataSync()->getEntities() + ->entityClass($entityClass) + ->limit(50); + + if ($cursor !== null) { + $page->cursor($cursor); + } + + $result = $page->sync(); + + foreach ($result->getData() as $item) { + printf("- %s (%s)\n", $item->getId(), $item->getStatus()); + } + + $cursor = $result->getPage() !== null && $result->getPage()->hasNext() + ? $result->getPage()->getNextCursor() + : null; +} while ($cursor !== null); +// snippet.end + +// snippet.filter_and_sort +$activeVehicles = $pubnub->dataSync()->getEntities() + ->entityClass($entityClass) + ->filter("status == 'active'") + ->sort(['createdAt' => 'desc']) + ->limit(10) + ->sync(); + +printf("Found %d active vehicles\n", $activeVehicles->count()); +// snippet.end + +// snippet.users_and_channels +// Users and channels are predefined entity classes, so entityClass can be left out. +$user = $pubnub->dataSync()->createUser() + ->userId($userId) + ->entityClassVersion(1) + ->payload(['name' => 'Alice Johnson', 'email' => 'alice@example.com']) + ->sync(); + +$channel = $pubnub->dataSync()->createChannel() + ->channelId($channelId) + ->entityClassVersion(1) + ->payload(['name' => 'General Discussion']) + ->sync(); + +printf("Created user %s and channel %s\n", $user->getId(), $channel->getId()); +// snippet.end + +// snippet.memberships +$membership = $pubnub->dataSync()->createMembership() + ->channelId($channelId) + ->userId($userId) + ->relationshipClassVersion(1) + ->payload(['role' => 'moderator']) + ->sync(); + +$usersChannels = $pubnub->dataSync()->getMemberships() + ->userId($userId) + ->sync(); + +printf("%s belongs to %d channel(s)\n", $userId, $usersChannels->count()); +// snippet.end + +// snippet.relationships +$relationship = $pubnub->dataSync()->createRelationship() + ->entityAId($userId) + ->entityBId($vehicleId) + ->relationshipClass($relationshipClass) + ->relationshipClassVersion(1) + ->payload(['since' => '2026-01-01']) + ->sync(); + +$owned = $pubnub->dataSync()->getRelationships() + ->relationshipClass($relationshipClass) + ->entityAId($userId) + ->sync(); + +printf("%s owns %d vehicle(s)\n", $userId, $owned->count()); +// snippet.end + +// snippet.error_handling +// envelope() returns the result and the status side by side instead of throwing. +$envelope = $pubnub->dataSync()->getEntity()->entityId('does-not-exist')->envelope(); + +if ($envelope->isError()) { + printf("Lookup failed with HTTP %d\n", $envelope->getStatus()->getStatusCode()); +} else { + printf("Found %s\n", $envelope->getResult()->getId()); +} +// snippet.end + +// snippet.delete +$pubnub->dataSync()->deleteRelationship()->relationshipId($relationship->getId())->sync(); +$pubnub->dataSync()->deleteMembership()->membershipId($membership->getId())->sync(); +$pubnub->dataSync()->deleteEntity()->entityId($vehicleId)->sync(); +$pubnub->dataSync()->deleteUser()->userId($userId)->sync(); +$pubnub->dataSync()->deleteChannel()->channelId($channelId)->sync(); + +echo "Cleaned up\n"; +// snippet.end + +// snippet.events +// DataSync change notifications arrive over the subscribe stream on a channel named after the +// record's identifier. PHP's subscribe loop blocks forever, so this only makes sense in a +// long-running CLI process, never inside a web request. +class DataSyncListener extends SubscribeCallback +{ + public function status($pubnub, $status) + { + } + + public function message($pubnub, $message) + { + } + + public function presence($pubnub, $presence) + { + } + + public function dataSyncEvent($pubnub, $event) + { + printf( + "%s %s %s (class %s v%d)\n", + $event->getEvent(), + $event->getType(), + $event->getId(), + $event->getClassName(), + $event->getClassVersion() + ); + + if ($event->getEntity() !== null) { + print_r($event->getEntity()->getPayload()); + } + } +} + +// $pubnub->addListener(new DataSyncListener()); +// $pubnub->subscribe()->channels($vehicleId)->execute(); +// snippet.end + +// snippet.access_control +// Granting DataSync access needs a secret key, so it belongs on a server you control rather than +// in the client that will use the token. +$secretKey = getenv('SECRET_KEY'); + +if ($secretKey) { + $adminConfig = new PNConfiguration(); + $adminConfig->setSubscribeKey($subscribeKey); + $adminConfig->setPublishKey($publishKey); + $adminConfig->setSecretKey($secretKey); + $adminConfig->setUserId('php-datasync-admin'); + + $admin = new PubNub($adminConfig); + + // A projection names the subset of fields the holder may see. "__default__" is the full record. + $token = $admin->grantToken() + ->ttl(60) + ->authorizedUuid($userId) + ->addDataSyncEntityResources([$vehicleId => ['read' => true, 'update' => true]]) + ->addDataSyncMembershipPatterns(['^' . $userId . ':.*$' => ['read' => true]]) + ->dataSyncProjections([ + 'resources' => ['entities' => [$vehicleId => '__default__']], + 'patterns' => ['memberships' => ['^' . $userId . ':.*$' => 'summary']], + ]) + ->sync(); + + // Reading the grants back out of a token, for logging or for checking what a client was given. + $parsed = $admin->parseToken($token); + + $vehiclePermissions = $parsed->getDataSyncEntityResource($vehicleId); + printf( + "%s: read=%s update=%s delete=%s\n", + $vehicleId, + var_export($vehiclePermissions->hasRead(), true), + var_export($vehiclePermissions->hasUpdate(), true), + var_export($vehiclePermissions->hasDelete(), true) + ); + + $projections = $parsed->getDataSyncProjections(); + + if ($projections !== null) { + printf( + "entity projection: %s, membership pattern projection: %s\n", + $projections->getResources()->getEntityProjection($vehicleId), + $projections->getPatterns()->getMembershipProjection('^' . $userId . ':.*$') + ); + } +} +// snippet.end diff --git a/src/PubNub/Callbacks/SubscribeCallback.php b/src/PubNub/Callbacks/SubscribeCallback.php index a5c43630..7f52ab6a 100644 --- a/src/PubNub/Callbacks/SubscribeCallback.php +++ b/src/PubNub/Callbacks/SubscribeCallback.php @@ -2,7 +2,7 @@ namespace PubNub\Callbacks; - +use PubNub\Models\Consumer\DataSync\PNDataSyncEventResult; use PubNub\Models\ResponseHelpers\PNStatus; use PubNub\PubNub; @@ -12,14 +12,27 @@ abstract class SubscribeCallback * @param PubNub $pubnub * @param PNStatus $status */ - abstract function status($pubnub, $status); + abstract public function status($pubnub, $status); // TODO: add annotation - abstract function message($pubnub, $message); + abstract public function message($pubnub, $message); // TODO: add annotation - abstract function presence($pubnub, $presence); + abstract public function presence($pubnub, $presence); // Not marked as abstract for backward compatibility reasons. - function signal($pubnub, $signal) {} -} \ No newline at end of file + public function signal($pubnub, $signal) + { + } + + /** + * Not marked as abstract for backward compatibility reasons. + * + * @param PubNub $pubnub + * @param PNDataSyncEventResult $event + * @return void + */ + public function dataSyncEvent($pubnub, $event) + { + } +} diff --git a/src/PubNub/DataSync.php b/src/PubNub/DataSync.php new file mode 100644 index 00000000..4fc6ee0d --- /dev/null +++ b/src/PubNub/DataSync.php @@ -0,0 +1,209 @@ +dataSync(). + * + * Every method hands back a fresh fluent builder, so a call reads as one chain: + * + * $entity = $pubnub->dataSync() + * ->createEntity() + * ->entityClass('vehicle') + * ->entityClassVersion(1) + * ->payload(['make' => 'Toyota']) + * ->sync(); + * + * Use sync() to get the typed result and have failures thrown, or envelope() to get the result + * and the status side by side without exceptions. + */ +class DataSync +{ + protected PubNub $pubnub; + + public function __construct(PubNub $pubnub) + { + $this->pubnub = $pubnub; + } + + public function createEntity(): CreateEntity + { + return new CreateEntity($this->pubnub); + } + + public function getEntity(): GetEntity + { + return new GetEntity($this->pubnub); + } + + public function getEntities(): GetEntities + { + return new GetEntities($this->pubnub); + } + + public function setEntity(): SetEntity + { + return new SetEntity($this->pubnub); + } + + public function updateEntity(): UpdateEntity + { + return new UpdateEntity($this->pubnub); + } + + public function deleteEntity(): DeleteEntity + { + return new DeleteEntity($this->pubnub); + } + + public function createRelationship(): CreateRelationship + { + return new CreateRelationship($this->pubnub); + } + + public function getRelationship(): GetRelationship + { + return new GetRelationship($this->pubnub); + } + + public function getRelationships(): GetRelationships + { + return new GetRelationships($this->pubnub); + } + + public function setRelationship(): SetRelationship + { + return new SetRelationship($this->pubnub); + } + + public function updateRelationship(): UpdateRelationship + { + return new UpdateRelationship($this->pubnub); + } + + public function deleteRelationship(): DeleteRelationship + { + return new DeleteRelationship($this->pubnub); + } + + public function createUser(): CreateUser + { + return new CreateUser($this->pubnub); + } + + public function getUser(): GetUser + { + return new GetUser($this->pubnub); + } + + public function getUsers(): GetUsers + { + return new GetUsers($this->pubnub); + } + + public function setUser(): SetUser + { + return new SetUser($this->pubnub); + } + + public function updateUser(): UpdateUser + { + return new UpdateUser($this->pubnub); + } + + public function deleteUser(): DeleteUser + { + return new DeleteUser($this->pubnub); + } + + public function createChannel(): CreateChannel + { + return new CreateChannel($this->pubnub); + } + + public function getChannel(): GetChannel + { + return new GetChannel($this->pubnub); + } + + public function getChannels(): GetChannels + { + return new GetChannels($this->pubnub); + } + + public function setChannel(): SetChannel + { + return new SetChannel($this->pubnub); + } + + public function updateChannel(): UpdateChannel + { + return new UpdateChannel($this->pubnub); + } + + public function deleteChannel(): DeleteChannel + { + return new DeleteChannel($this->pubnub); + } + + public function createMembership(): CreateMembership + { + return new CreateMembership($this->pubnub); + } + + public function getMembership(): GetMembership + { + return new GetMembership($this->pubnub); + } + + public function getMemberships(): GetMemberships + { + return new GetMemberships($this->pubnub); + } + + public function setMembership(): SetMembership + { + return new SetMembership($this->pubnub); + } + + public function updateMembership(): UpdateMembership + { + return new UpdateMembership($this->pubnub); + } + + public function deleteMembership(): DeleteMembership + { + return new DeleteMembership($this->pubnub); + } +} diff --git a/src/PubNub/Endpoints/Access/GrantToken.php b/src/PubNub/Endpoints/Access/GrantToken.php index a9d7afe3..5e3519aa 100644 --- a/src/PubNub/Endpoints/Access/GrantToken.php +++ b/src/PubNub/Endpoints/Access/GrantToken.php @@ -32,6 +32,9 @@ class GrantToken extends Endpoint /** @var bool */ protected $sortParams = true; + /** @var array>> */ + protected $dataSyncProjections = []; + private $channels = []; private $groups = []; @@ -179,6 +182,128 @@ public function addUuidPatterns($res) return $this; } + /** + * @param array> $res + * @return $this + */ + public function addDataSyncEntityResources($res) + { + $this->addResources('datasync:entities', $res); + return $this; + } + + /** + * @param array> $res + * @return $this + */ + public function addDataSyncRelationshipResources($res) + { + $this->addResources('datasync:relationships', $res); + return $this; + } + + /** + * @param array> $res + * @return $this + */ + public function addDataSyncMembershipResources($res) + { + $this->addResources('datasync:memberships', $res); + return $this; + } + + /** + * @param array> $res + * @return $this + */ + public function addDataSyncEntityPatterns($res) + { + $this->addPatterns('datasync:entities', $res); + return $this; + } + + /** + * @param array> $res + * @return $this + */ + public function addDataSyncRelationshipPatterns($res) + { + $this->addPatterns('datasync:relationships', $res); + return $this; + } + + /** + * @param array> $res + * @return $this + */ + public function addDataSyncMembershipPatterns($res) + { + $this->addPatterns('datasync:memberships', $res); + return $this; + } + + /** + * Restricts which fields of a DataSync record the token holder can see. + * + * Expects up to two scopes, "resources" for exact identifiers and "patterns" for regular + * expressions, each holding any of "entities", "relationships" and "memberships" mapped from + * identifier to projection name. Use "__default__" for the base projection: + * + * ->dataSyncProjections([ + * 'resources' => ['entities' => ['vehicle-1' => '__default__']], + * 'patterns' => ['entities' => ['^vehicle-.*$' => 'public']], + * ]) + * + * @param array>> $projections + * @return $this + */ + public function dataSyncProjections($projections) + { + $this->dataSyncProjections = $projections; + return $this; + } + + /** + * Flattens the projection scopes into the composite keys the server expects. + * + * @return array> + */ + private function buildProjectionsMeta() + { + $scopeKeys = ['resources' => 'res', 'patterns' => 'pat']; + $result = []; + + foreach ($scopeKeys as $scopeName => $shortName) { + if (!array_key_exists($scopeName, $this->dataSyncProjections)) { + continue; + } + + $flat = []; + + foreach (['entities', 'relationships', 'memberships'] as $type) { + $map = $this->dataSyncProjections[$scopeName][$type] ?? null; + + if (!is_array($map)) { + continue; + } + + foreach ($map as $name => $projection) { + if ($name === '') { + continue; + } + + $flat["datasync:$type:$name"] = $projection; + } + } + + if (count($flat) > 0) { + $result[$shortName] = $flat; + } + } + + return $result; + } + /** * @throws PubNubValidationException */ @@ -224,6 +349,19 @@ public function buildData() $params['permissions']['meta'] = $this->meta; } + $projections = $this->buildProjectionsMeta(); + + if (count($projections) > 0) { + $meta = $params['permissions']['meta'] ?? []; + + if (!is_array($meta)) { + $meta = (array) $meta; + } + + $meta['pn-projections'] = $projections; + $params['permissions']['meta'] = $meta; + } + return json_encode($params); } diff --git a/src/PubNub/Endpoints/DataSync/Channel/CreateChannel.php b/src/PubNub/Endpoints/DataSync/Channel/CreateChannel.php new file mode 100644 index 00000000..d0c9a5e1 --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Channel/CreateChannel.php @@ -0,0 +1,122 @@ +channelId = $channelId; + return $this; + } + + /** + * @return $this + */ + public function entityClass(string $entityClass): static + { + $this->entityClass = $entityClass; + return $this; + } + + /** + * @return $this + */ + public function entityClassVersion(int $entityClassVersion): static + { + $this->entityClassVersion = $entityClassVersion; + return $this; + } + + /** + * Either "Global" or "SubKey". + * + * @return $this + */ + public function entityClassLevel(string $entityClassLevel): static + { + $this->entityClassLevel = $entityClassLevel; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + $this->validateClassVersion($this->entityClassVersion, "entityClassVersion"); + } + + /** + * @return string + */ + protected function buildData() + { + $data = []; + + if ($this->channelId !== null && $this->channelId !== '') { + $data['id'] = $this->channelId; + } + + if ($this->entityClass !== null && $this->entityClass !== '') { + $data['entityClass'] = $this->entityClass; + } + + $data['entityClassVersion'] = $this->entityClassVersion; + + if ($this->entityClassLevel !== null && $this->entityClassLevel !== '') { + $data['entityClassLevel'] = $this->entityClassLevel; + } + + return $this->envelopeData($this->withPayloadAndStatus($data)); + } + + public function sync(): PNDataSyncChannelResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncChannelResult + { + return PNDataSyncChannelResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/Channel/DeleteChannel.php b/src/PubNub/Endpoints/DataSync/Channel/DeleteChannel.php new file mode 100644 index 00000000..03b3c4e3 --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Channel/DeleteChannel.php @@ -0,0 +1,56 @@ +id = $channelId; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + $this->validateId("channelId"); + } + + public function sync(): PNDataSyncDeleteResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncDeleteResult + { + return PNDataSyncDeleteResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/Channel/GetChannel.php b/src/PubNub/Endpoints/DataSync/Channel/GetChannel.php new file mode 100644 index 00000000..5795404c --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Channel/GetChannel.php @@ -0,0 +1,53 @@ +id = $channelId; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + $this->validateId("channelId"); + } + + public function sync(): PNDataSyncChannelResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncChannelResult + { + return PNDataSyncChannelResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/Channel/GetChannels.php b/src/PubNub/Endpoints/DataSync/Channel/GetChannels.php new file mode 100644 index 00000000..623d83dd --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Channel/GetChannels.php @@ -0,0 +1,104 @@ +entityClass = $entityClass; + return $this; + } + + /** + * Defaults to the latest version of the class when omitted. + * + * @return $this + */ + public function entityClassVersion(int $entityClassVersion): static + { + $this->entityClassVersion = $entityClassVersion; + return $this; + } + + /** + * Either "Global" or "SubKey". + * + * @return $this + */ + public function entityClassLevel(string $entityClassLevel): static + { + $this->entityClassLevel = $entityClassLevel; + return $this; + } + + protected function validateParams(): void + { + $this->validateSubscribeKey(); + } + + /** + * @return array + */ + protected function customParams() + { + $params = array_merge($this->defaultParams(), $this->collectionParams()); + + if (!empty($this->entityClass)) { + $params['entity_class'] = PubNubUtil::urlEncode($this->entityClass); + } + + if ($this->entityClassVersion !== null) { + $params['entity_class_version'] = (string) $this->entityClassVersion; + } + + if (!empty($this->entityClassLevel)) { + $params['entity_class_level'] = PubNubUtil::urlEncode($this->entityClassLevel); + } + + return $params; + } + + public function sync(): PNDataSyncChannelsResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncChannelsResult + { + return PNDataSyncChannelsResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/Channel/SetChannel.php b/src/PubNub/Endpoints/DataSync/Channel/SetChannel.php new file mode 100644 index 00000000..b27fd75f --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Channel/SetChannel.php @@ -0,0 +1,82 @@ +id = $channelId; + return $this; + } + + /** + * @return $this + */ + public function entityClassVersion(int $entityClassVersion): static + { + $this->entityClassVersion = $entityClassVersion; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + $this->validateId("channelId"); + $this->validateClassVersion($this->entityClassVersion, "entityClassVersion"); + } + + /** + * @return string + */ + protected function buildData() + { + $data = ['entityClassVersion' => $this->entityClassVersion]; + + return $this->envelopeData($this->withPayloadAndStatus($data)); + } + + public function sync(): PNDataSyncChannelResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncChannelResult + { + return PNDataSyncChannelResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/Channel/UpdateChannel.php b/src/PubNub/Endpoints/DataSync/Channel/UpdateChannel.php new file mode 100644 index 00000000..2a9f35da --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Channel/UpdateChannel.php @@ -0,0 +1,67 @@ +id = $channelId; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + $this->validateId("channelId"); + $this->validatePatch(); + } + + /** + * @return string + */ + protected function buildData() + { + return $this->buildPatchData(); + } + + public function sync(): PNDataSyncChannelResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncChannelResult + { + return PNDataSyncChannelResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/DataSyncCollectionEndpoint.php b/src/PubNub/Endpoints/DataSync/DataSyncCollectionEndpoint.php new file mode 100644 index 00000000..69f2a26c --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/DataSyncCollectionEndpoint.php @@ -0,0 +1,149 @@ +|string|null */ + protected $sort = null; + + /** + * Opaque token identifying the next page, taken from meta.next_cursor of the previous response. + * + * @return $this + */ + public function cursor(string $cursor): static + { + $this->cursor = $cursor; + return $this; + } + + /** + * Maximum number of items per page. The server defaults to 20 and caps at 100. + * + * @return $this + */ + public function limit(int $limit): static + { + $this->limit = $limit; + return $this; + } + + /** + * Filter expression in the App Context Query Language, evaluated against strongly consistent + * storage. Supports only a limited number of conditions. + * + * Only properties declared with a `filtering` mode other than `none` in the class registry + * can be filtered on. + * + * @return $this + */ + public function filterFast(string $filterFast): static + { + $this->filterFast = $filterFast; + return $this; + } + + /** + * Filter expression in the App Context Query Language, evaluated against eventually consistent + * storage. Supports logical operators and nested conditions. + * + * @return $this + */ + public function filter(string $filter): static + { + $this->filter = $filter; + return $this; + } + + /** + * Accepts either a ready-made string ("name:desc,type") or an array + * (['name' => 'desc', 'type']) that gets joined into one. + * + * Only properties declared with a `filtering` mode other than `none` in the class registry + * can be sorted on. + * + * @param array|string $sort + * @return $this + */ + public function sort($sort): static + { + $this->sort = $sort; + return $this; + } + + /** + * Query parameters shared by every list endpoint, already URL-encoded. + * + * @return array + */ + protected function collectionParams(): array + { + $params = []; + + if (!empty($this->cursor)) { + $params['cursor'] = PubNubUtil::urlEncode($this->cursor); + } + + if ($this->limit !== null) { + $params['limit'] = (string) $this->limit; + } + + if (!empty($this->filterFast)) { + $params['filter_fast'] = PubNubUtil::urlEncode($this->filterFast); + } + + if (!empty($this->filter)) { + $params['filter'] = PubNubUtil::urlEncode($this->filter); + } + + $sort = $this->buildSortValue(); + + if ($sort !== null) { + $params['sort'] = PubNubUtil::urlEncode($sort); + } + + return $params; + } + + private function buildSortValue(): ?string + { + if (empty($this->sort)) { + return null; + } + + if (is_string($this->sort)) { + return $this->sort; + } + + $entries = []; + + foreach ($this->sort as $key => $value) { + if (is_int($key)) { + $entries[] = $value; + } elseif ($value === 'asc' || $value === 'desc') { + $entries[] = "$key:$value"; + } else { + $entries[] = $key; + } + } + + return join(",", $entries); + } +} diff --git a/src/PubNub/Endpoints/DataSync/DataSyncEndpoint.php b/src/PubNub/Endpoints/DataSync/DataSyncEndpoint.php new file mode 100644 index 00000000..c7ba19b5 --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/DataSyncEndpoint.php @@ -0,0 +1,142 @@ +endpointRequestTimeout = $pubnubInstance->getConfiguration()->getNonSubscribeRequestTimeout(); + $this->endpointConnectTimeout = $pubnubInstance->getConfiguration()->getConnectTimeout(); + } + + /** + * @return string|null + */ + protected function buildData() + { + return null; + } + + /** + * @return string + */ + protected function buildPath() + { + $path = sprintf( + static::PATH, + $this->pubnub->getConfiguration()->getSubscribeKey(), + static::RESOURCE + ); + + if ($this->id !== null && $this->id !== '') { + $path .= '/' . PubNubUtil::urlEncode($this->id); + } + + return $path; + } + + /** + * @return array + */ + protected function customParams() + { + return $this->defaultParams(); + } + + /** + * @return array + */ + protected function defaultHeaders() + { + // No Accept header: a delete answers with an empty body and no media type, so content + // negotiation rejects anything specific there with a 406. + return ['Connection' => 'Keep-Alive']; + } + + /** + * @return array + */ + protected function customHeaders() + { + $headers = $this->customHeaders; + $contentType = $this->contentType(); + + if ($contentType !== null) { + $headers['Content-Type'] = $contentType; + } + + return $headers; + } + + /** + * Content type of the request body, or null when the request carries no body. + */ + protected function contentType(): ?string + { + return match ($this->httpMethod()) { + PNHttpMethod::POST, PNHttpMethod::PUT => static::MEDIA_TYPE, + PNHttpMethod::PATCH => static::PATCH_MEDIA_TYPE, + default => null, + }; + } + + /** + * Wraps write payloads in the {"data": ...} request envelope the API expects. + * + * @param array $data + */ + protected function envelopeData(array $data): string + { + return PubNubUtil::writeValueAsString(['data' => $data]); + } + + /** + * @throws PubNubValidationException + */ + protected function validateId(string $label): void + { + if ($this->id === null || trim($this->id) === '') { + throw new PubNubValidationException("$label missing"); + } + } + + /** + * @throws PubNubValidationException + */ + protected function validateClassVersion(?int $version, string $label): void + { + if ($version === null || $version < 1) { + throw new PubNubValidationException("$label must be greater than or equal to 1"); + } + } +} diff --git a/src/PubNub/Endpoints/DataSync/Entity/CreateEntity.php b/src/PubNub/Endpoints/DataSync/Entity/CreateEntity.php new file mode 100644 index 00000000..7e39bb84 --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Entity/CreateEntity.php @@ -0,0 +1,125 @@ +entityId = $entityId; + return $this; + } + + /** + * @return $this + */ + public function entityClass(string $entityClass): static + { + $this->entityClass = $entityClass; + return $this; + } + + /** + * @return $this + */ + public function entityClassVersion(int $entityClassVersion): static + { + $this->entityClassVersion = $entityClassVersion; + return $this; + } + + /** + * Either "Global" or "SubKey". Only needed to disambiguate two classes that share a name at + * different levels; otherwise the server resolves SubKey before Global. + * + * @return $this + */ + public function entityClassLevel(string $entityClassLevel): static + { + $this->entityClassLevel = $entityClassLevel; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + + if ($this->entityClass === null || trim($this->entityClass) === '') { + throw new PubNubValidationException("entityClass missing"); + } + + $this->validateClassVersion($this->entityClassVersion, "entityClassVersion"); + } + + /** + * @return string + */ + protected function buildData() + { + $data = []; + + if ($this->entityId !== null && $this->entityId !== '') { + $data['id'] = $this->entityId; + } + + $data['entityClass'] = $this->entityClass; + $data['entityClassVersion'] = $this->entityClassVersion; + + if ($this->entityClassLevel !== null && $this->entityClassLevel !== '') { + $data['entityClassLevel'] = $this->entityClassLevel; + } + + return $this->envelopeData($this->withPayloadAndStatus($data)); + } + + public function sync(): PNDataSyncEntityResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncEntityResult + { + return PNDataSyncEntityResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/Entity/DeleteEntity.php b/src/PubNub/Endpoints/DataSync/Entity/DeleteEntity.php new file mode 100644 index 00000000..029598ea --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Entity/DeleteEntity.php @@ -0,0 +1,56 @@ +id = $entityId; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + $this->validateId("entityId"); + } + + public function sync(): PNDataSyncDeleteResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncDeleteResult + { + return PNDataSyncDeleteResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/Entity/GetEntities.php b/src/PubNub/Endpoints/DataSync/Entity/GetEntities.php new file mode 100644 index 00000000..7e4f758b --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Entity/GetEntities.php @@ -0,0 +1,105 @@ +entityClass = $entityClass; + return $this; + } + + /** + * Defaults to the latest version of the class when omitted. + * + * @return $this + */ + public function entityClassVersion(int $entityClassVersion): static + { + $this->entityClassVersion = $entityClassVersion; + return $this; + } + + /** + * Either "Global" or "SubKey"; defaults to the standard SubKey then Global resolution. + * + * @return $this + */ + public function entityClassLevel(string $entityClassLevel): static + { + $this->entityClassLevel = $entityClassLevel; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + + if ($this->entityClass === null || trim($this->entityClass) === '') { + throw new PubNubValidationException("entityClass missing"); + } + } + + /** + * @return array + */ + protected function customParams() + { + $params = array_merge($this->defaultParams(), $this->collectionParams()); + + $params['entity_class'] = PubNubUtil::urlEncode((string) $this->entityClass); + + if ($this->entityClassVersion !== null) { + $params['entity_class_version'] = (string) $this->entityClassVersion; + } + + if (!empty($this->entityClassLevel)) { + $params['entity_class_level'] = PubNubUtil::urlEncode($this->entityClassLevel); + } + + return $params; + } + + public function sync(): PNDataSyncEntitiesResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncEntitiesResult + { + return PNDataSyncEntitiesResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/Entity/GetEntity.php b/src/PubNub/Endpoints/DataSync/Entity/GetEntity.php new file mode 100644 index 00000000..63e01b9b --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Entity/GetEntity.php @@ -0,0 +1,53 @@ +id = $entityId; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + $this->validateId("entityId"); + } + + public function sync(): PNDataSyncEntityResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncEntityResult + { + return PNDataSyncEntityResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/Entity/SetEntity.php b/src/PubNub/Endpoints/DataSync/Entity/SetEntity.php new file mode 100644 index 00000000..6a33fbab --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Entity/SetEntity.php @@ -0,0 +1,83 @@ +id = $entityId; + return $this; + } + + /** + * @return $this + */ + public function entityClassVersion(int $entityClassVersion): static + { + $this->entityClassVersion = $entityClassVersion; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + $this->validateId("entityId"); + $this->validateClassVersion($this->entityClassVersion, "entityClassVersion"); + } + + /** + * @return string + */ + protected function buildData() + { + $data = ['entityClassVersion' => $this->entityClassVersion]; + + return $this->envelopeData($this->withPayloadAndStatus($data)); + } + + public function sync(): PNDataSyncEntityResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncEntityResult + { + return PNDataSyncEntityResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/Entity/UpdateEntity.php b/src/PubNub/Endpoints/DataSync/Entity/UpdateEntity.php new file mode 100644 index 00000000..cfba2b93 --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Entity/UpdateEntity.php @@ -0,0 +1,67 @@ +id = $entityId; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + $this->validateId("entityId"); + $this->validatePatch(); + } + + /** + * @return string + */ + protected function buildData() + { + return $this->buildPatchData(); + } + + public function sync(): PNDataSyncEntityResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncEntityResult + { + return PNDataSyncEntityResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/Membership/CreateMembership.php b/src/PubNub/Endpoints/DataSync/Membership/CreateMembership.php new file mode 100644 index 00000000..8b5052cd --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Membership/CreateMembership.php @@ -0,0 +1,123 @@ +membershipId = $membershipId; + return $this; + } + + /** + * @return $this + */ + public function channelId(string $channelId): static + { + $this->channelId = $channelId; + return $this; + } + + /** + * @return $this + */ + public function userId(string $userId): static + { + $this->userId = $userId; + return $this; + } + + /** + * @return $this + */ + public function relationshipClassVersion(int $relationshipClassVersion): static + { + $this->relationshipClassVersion = $relationshipClassVersion; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + + if ($this->channelId === null || trim($this->channelId) === '') { + throw new PubNubValidationException("channelId missing"); + } + + if ($this->userId === null || trim($this->userId) === '') { + throw new PubNubValidationException("userId missing"); + } + + $this->validateClassVersion($this->relationshipClassVersion, "relationshipClassVersion"); + } + + /** + * @return string + */ + protected function buildData() + { + $data = []; + + if ($this->membershipId !== null && $this->membershipId !== '') { + $data['id'] = $this->membershipId; + } + + $data['channelId'] = $this->channelId; + $data['userId'] = $this->userId; + $data['relationshipClassVersion'] = $this->relationshipClassVersion; + + return $this->envelopeData($this->withPayloadAndStatus($data)); + } + + public function sync(): PNDataSyncMembershipResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncMembershipResult + { + return PNDataSyncMembershipResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/Membership/DeleteMembership.php b/src/PubNub/Endpoints/DataSync/Membership/DeleteMembership.php new file mode 100644 index 00000000..4d7af1bf --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Membership/DeleteMembership.php @@ -0,0 +1,56 @@ +id = $membershipId; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + $this->validateId("membershipId"); + } + + public function sync(): PNDataSyncDeleteResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncDeleteResult + { + return PNDataSyncDeleteResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/Membership/GetMembership.php b/src/PubNub/Endpoints/DataSync/Membership/GetMembership.php new file mode 100644 index 00000000..fbaaadd7 --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Membership/GetMembership.php @@ -0,0 +1,53 @@ +id = $membershipId; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + $this->validateId("membershipId"); + } + + public function sync(): PNDataSyncMembershipResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncMembershipResult + { + return PNDataSyncMembershipResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/Membership/GetMemberships.php b/src/PubNub/Endpoints/DataSync/Membership/GetMemberships.php new file mode 100644 index 00000000..837034cb --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Membership/GetMemberships.php @@ -0,0 +1,99 @@ +channelId = $channelId; + return $this; + } + + /** + * @return $this + */ + public function userId(string $userId): static + { + $this->userId = $userId; + return $this; + } + + /** + * Defaults to the latest version of the class when omitted. + * + * @return $this + */ + public function relationshipClassVersion(int $relationshipClassVersion): static + { + $this->relationshipClassVersion = $relationshipClassVersion; + return $this; + } + + protected function validateParams(): void + { + $this->validateSubscribeKey(); + } + + /** + * @return array + */ + protected function customParams() + { + $params = array_merge($this->defaultParams(), $this->collectionParams()); + + if (!empty($this->channelId)) { + $params['channel_id'] = PubNubUtil::urlEncode($this->channelId); + } + + if (!empty($this->userId)) { + $params['user_id'] = PubNubUtil::urlEncode($this->userId); + } + + if ($this->relationshipClassVersion !== null) { + $params['relationship_class_version'] = (string) $this->relationshipClassVersion; + } + + return $params; + } + + public function sync(): PNDataSyncMembershipsResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncMembershipsResult + { + return PNDataSyncMembershipsResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/Membership/SetMembership.php b/src/PubNub/Endpoints/DataSync/Membership/SetMembership.php new file mode 100644 index 00000000..499123aa --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Membership/SetMembership.php @@ -0,0 +1,82 @@ +id = $membershipId; + return $this; + } + + /** + * @return $this + */ + public function relationshipClassVersion(int $relationshipClassVersion): static + { + $this->relationshipClassVersion = $relationshipClassVersion; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + $this->validateId("membershipId"); + $this->validateClassVersion($this->relationshipClassVersion, "relationshipClassVersion"); + } + + /** + * @return string + */ + protected function buildData() + { + $data = ['relationshipClassVersion' => $this->relationshipClassVersion]; + + return $this->envelopeData($this->withPayloadAndStatus($data)); + } + + public function sync(): PNDataSyncMembershipResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncMembershipResult + { + return PNDataSyncMembershipResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/Membership/UpdateMembership.php b/src/PubNub/Endpoints/DataSync/Membership/UpdateMembership.php new file mode 100644 index 00000000..38b79fc9 --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Membership/UpdateMembership.php @@ -0,0 +1,67 @@ +id = $membershipId; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + $this->validateId("membershipId"); + $this->validatePatch(); + } + + /** + * @return string + */ + protected function buildData() + { + return $this->buildPatchData(); + } + + public function sync(): PNDataSyncMembershipResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncMembershipResult + { + return PNDataSyncMembershipResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/Relationship/CreateRelationship.php b/src/PubNub/Endpoints/DataSync/Relationship/CreateRelationship.php new file mode 100644 index 00000000..e9368243 --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Relationship/CreateRelationship.php @@ -0,0 +1,140 @@ +relationshipId = $relationshipId; + return $this; + } + + /** + * Identifier of the entity on the source side of the relationship. + * + * @return $this + */ + public function entityAId(string $entityAId): static + { + $this->entityAId = $entityAId; + return $this; + } + + /** + * Identifier of the entity on the target side of the relationship. + * + * @return $this + */ + public function entityBId(string $entityBId): static + { + $this->entityBId = $entityBId; + return $this; + } + + /** + * @return $this + */ + public function relationshipClass(string $relationshipClass): static + { + $this->relationshipClass = $relationshipClass; + return $this; + } + + /** + * @return $this + */ + public function relationshipClassVersion(int $relationshipClassVersion): static + { + $this->relationshipClassVersion = $relationshipClassVersion; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + + if ($this->entityAId === null || trim($this->entityAId) === '') { + throw new PubNubValidationException("entityAId missing"); + } + + if ($this->entityBId === null || trim($this->entityBId) === '') { + throw new PubNubValidationException("entityBId missing"); + } + + if ($this->relationshipClass === null || trim($this->relationshipClass) === '') { + throw new PubNubValidationException("relationshipClass missing"); + } + + $this->validateClassVersion($this->relationshipClassVersion, "relationshipClassVersion"); + } + + /** + * @return string + */ + protected function buildData() + { + $data = []; + + if ($this->relationshipId !== null && $this->relationshipId !== '') { + $data['id'] = $this->relationshipId; + } + + $data['entityAId'] = $this->entityAId; + $data['entityBId'] = $this->entityBId; + $data['relationshipClass'] = $this->relationshipClass; + $data['relationshipClassVersion'] = $this->relationshipClassVersion; + + return $this->envelopeData($this->withPayloadAndStatus($data)); + } + + public function sync(): PNDataSyncRelationshipResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncRelationshipResult + { + return PNDataSyncRelationshipResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/Relationship/DeleteRelationship.php b/src/PubNub/Endpoints/DataSync/Relationship/DeleteRelationship.php new file mode 100644 index 00000000..90179c6c --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Relationship/DeleteRelationship.php @@ -0,0 +1,56 @@ +id = $relationshipId; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + $this->validateId("relationshipId"); + } + + public function sync(): PNDataSyncDeleteResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncDeleteResult + { + return PNDataSyncDeleteResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/Relationship/GetRelationship.php b/src/PubNub/Endpoints/DataSync/Relationship/GetRelationship.php new file mode 100644 index 00000000..cfc1d55a --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Relationship/GetRelationship.php @@ -0,0 +1,53 @@ +id = $relationshipId; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + $this->validateId("relationshipId"); + } + + public function sync(): PNDataSyncRelationshipResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncRelationshipResult + { + return PNDataSyncRelationshipResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/Relationship/GetRelationships.php b/src/PubNub/Endpoints/DataSync/Relationship/GetRelationships.php new file mode 100644 index 00000000..cbc0fea0 --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Relationship/GetRelationships.php @@ -0,0 +1,120 @@ +relationshipClass = $relationshipClass; + return $this; + } + + /** + * Defaults to the latest version of the class when omitted. + * + * @return $this + */ + public function relationshipClassVersion(int $relationshipClassVersion): static + { + $this->relationshipClassVersion = $relationshipClassVersion; + return $this; + } + + /** + * @return $this + */ + public function entityAId(string $entityAId): static + { + $this->entityAId = $entityAId; + return $this; + } + + /** + * @return $this + */ + public function entityBId(string $entityBId): static + { + $this->entityBId = $entityBId; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + + if ($this->relationshipClass === null || trim($this->relationshipClass) === '') { + throw new PubNubValidationException("relationshipClass missing"); + } + } + + /** + * @return array + */ + protected function customParams() + { + $params = array_merge($this->defaultParams(), $this->collectionParams()); + + $params['relationship_class'] = PubNubUtil::urlEncode((string) $this->relationshipClass); + + if ($this->relationshipClassVersion !== null) { + $params['relationship_class_version'] = (string) $this->relationshipClassVersion; + } + + if (!empty($this->entityAId)) { + $params['entity_a_id'] = PubNubUtil::urlEncode($this->entityAId); + } + + if (!empty($this->entityBId)) { + $params['entity_b_id'] = PubNubUtil::urlEncode($this->entityBId); + } + + return $params; + } + + public function sync(): PNDataSyncRelationshipsResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncRelationshipsResult + { + return PNDataSyncRelationshipsResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/Relationship/SetRelationship.php b/src/PubNub/Endpoints/DataSync/Relationship/SetRelationship.php new file mode 100644 index 00000000..d0609132 --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Relationship/SetRelationship.php @@ -0,0 +1,82 @@ +id = $relationshipId; + return $this; + } + + /** + * @return $this + */ + public function relationshipClassVersion(int $relationshipClassVersion): static + { + $this->relationshipClassVersion = $relationshipClassVersion; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + $this->validateId("relationshipId"); + $this->validateClassVersion($this->relationshipClassVersion, "relationshipClassVersion"); + } + + /** + * @return string + */ + protected function buildData() + { + $data = ['relationshipClassVersion' => $this->relationshipClassVersion]; + + return $this->envelopeData($this->withPayloadAndStatus($data)); + } + + public function sync(): PNDataSyncRelationshipResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncRelationshipResult + { + return PNDataSyncRelationshipResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/Relationship/UpdateRelationship.php b/src/PubNub/Endpoints/DataSync/Relationship/UpdateRelationship.php new file mode 100644 index 00000000..47faa860 --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Relationship/UpdateRelationship.php @@ -0,0 +1,67 @@ +id = $relationshipId; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + $this->validateId("relationshipId"); + $this->validatePatch(); + } + + /** + * @return string + */ + protected function buildData() + { + return $this->buildPatchData(); + } + + public function sync(): PNDataSyncRelationshipResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncRelationshipResult + { + return PNDataSyncRelationshipResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/Traits/HasIfMatch.php b/src/PubNub/Endpoints/DataSync/Traits/HasIfMatch.php new file mode 100644 index 00000000..5c564d59 --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Traits/HasIfMatch.php @@ -0,0 +1,24 @@ +eTag = $eTag; + $this->customHeaders['If-Match'] = $eTag; + return $this; + } +} diff --git a/src/PubNub/Endpoints/DataSync/Traits/HasJsonPatch.php b/src/PubNub/Endpoints/DataSync/Traits/HasJsonPatch.php new file mode 100644 index 00000000..e0e611c8 --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Traits/HasJsonPatch.php @@ -0,0 +1,83 @@ +|null */ + protected $patch = null; + + /** + * Accepts either the fluent builder or a raw RFC-6902 array for callers who prefer one. + * + * @param PNDataSyncPatch|array $patch + * @return $this + */ + public function patch($patch): static + { + $this->patch = $patch; + return $this; + } + + /** + * @return array + */ + protected function patchOperations(): array + { + if ($this->patch instanceof PNDataSyncPatch) { + return $this->patch->toArray(); + } + + return is_array($this->patch) ? $this->patch : []; + } + + /** + * @throws PubNubValidationException + */ + protected function validatePatch(): void + { + $operations = $this->patchOperations(); + + if (count($operations) === 0) { + throw new PubNubValidationException("patch operations missing"); + } + + $requiresValue = [PNDataSyncPatch::OP_ADD, PNDataSyncPatch::OP_REPLACE, PNDataSyncPatch::OP_TEST]; + $requiresFrom = [PNDataSyncPatch::OP_MOVE, PNDataSyncPatch::OP_COPY]; + + foreach ($operations as $index => $operation) { + if (!is_array($operation)) { + throw new PubNubValidationException("patch operation #$index must be an array"); + } + + if (!array_key_exists('op', $operation) || !array_key_exists('path', $operation)) { + throw new PubNubValidationException("patch operation #$index requires both op and path"); + } + + $op = $operation['op']; + + if (in_array($op, $requiresValue, true) && !array_key_exists('value', $operation)) { + throw new PubNubValidationException("patch operation \"$op\" requires a value"); + } + + if (in_array($op, $requiresFrom, true) && !array_key_exists('from', $operation)) { + throw new PubNubValidationException("patch operation \"$op\" requires a from path"); + } + } + } + + protected function buildPatchData(): string + { + return PubNubUtil::writeValueAsString($this->patchOperations()); + } +} diff --git a/src/PubNub/Endpoints/DataSync/Traits/HasPayloadAndStatus.php b/src/PubNub/Endpoints/DataSync/Traits/HasPayloadAndStatus.php new file mode 100644 index 00000000..c8aab706 --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Traits/HasPayloadAndStatus.php @@ -0,0 +1,53 @@ +|null */ + protected ?array $payload = null; + + /** + * @return $this + */ + public function status(string $status): static + { + $this->status = $status; + return $this; + } + + /** + * @param array $payload + * @return $this + */ + public function payload(array $payload): static + { + $this->payload = $payload; + return $this; + } + + /** + * Appends the optional status and payload keys, leaving them out entirely when unset. + * + * @param array $data + * @return array + */ + protected function withPayloadAndStatus(array $data): array + { + if ($this->status !== null && $this->status !== '') { + $data['status'] = $this->status; + } + + if ($this->payload !== null) { + // An empty PHP array encodes as [] rather than {}, which the server rejects. + $data['payload'] = (object) $this->payload; + } + + return $data; + } +} diff --git a/src/PubNub/Endpoints/DataSync/User/CreateUser.php b/src/PubNub/Endpoints/DataSync/User/CreateUser.php new file mode 100644 index 00000000..e85428f1 --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/User/CreateUser.php @@ -0,0 +1,122 @@ +userId = $userId; + return $this; + } + + /** + * @return $this + */ + public function entityClass(string $entityClass): static + { + $this->entityClass = $entityClass; + return $this; + } + + /** + * @return $this + */ + public function entityClassVersion(int $entityClassVersion): static + { + $this->entityClassVersion = $entityClassVersion; + return $this; + } + + /** + * Either "Global" or "SubKey". + * + * @return $this + */ + public function entityClassLevel(string $entityClassLevel): static + { + $this->entityClassLevel = $entityClassLevel; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + $this->validateClassVersion($this->entityClassVersion, "entityClassVersion"); + } + + /** + * @return string + */ + protected function buildData() + { + $data = []; + + if ($this->userId !== null && $this->userId !== '') { + $data['id'] = $this->userId; + } + + if ($this->entityClass !== null && $this->entityClass !== '') { + $data['entityClass'] = $this->entityClass; + } + + $data['entityClassVersion'] = $this->entityClassVersion; + + if ($this->entityClassLevel !== null && $this->entityClassLevel !== '') { + $data['entityClassLevel'] = $this->entityClassLevel; + } + + return $this->envelopeData($this->withPayloadAndStatus($data)); + } + + public function sync(): PNDataSyncUserResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncUserResult + { + return PNDataSyncUserResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/User/DeleteUser.php b/src/PubNub/Endpoints/DataSync/User/DeleteUser.php new file mode 100644 index 00000000..1e280559 --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/User/DeleteUser.php @@ -0,0 +1,56 @@ +id = $userId; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + $this->validateId("userId"); + } + + public function sync(): PNDataSyncDeleteResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncDeleteResult + { + return PNDataSyncDeleteResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/User/GetUser.php b/src/PubNub/Endpoints/DataSync/User/GetUser.php new file mode 100644 index 00000000..e937ccd5 --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/User/GetUser.php @@ -0,0 +1,53 @@ +id = $userId; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + $this->validateId("userId"); + } + + public function sync(): PNDataSyncUserResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncUserResult + { + return PNDataSyncUserResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/User/GetUsers.php b/src/PubNub/Endpoints/DataSync/User/GetUsers.php new file mode 100644 index 00000000..b401f020 --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/User/GetUsers.php @@ -0,0 +1,104 @@ +entityClass = $entityClass; + return $this; + } + + /** + * Defaults to the latest version of the class when omitted. + * + * @return $this + */ + public function entityClassVersion(int $entityClassVersion): static + { + $this->entityClassVersion = $entityClassVersion; + return $this; + } + + /** + * Either "Global" or "SubKey". + * + * @return $this + */ + public function entityClassLevel(string $entityClassLevel): static + { + $this->entityClassLevel = $entityClassLevel; + return $this; + } + + protected function validateParams(): void + { + $this->validateSubscribeKey(); + } + + /** + * @return array + */ + protected function customParams() + { + $params = array_merge($this->defaultParams(), $this->collectionParams()); + + if (!empty($this->entityClass)) { + $params['entity_class'] = PubNubUtil::urlEncode($this->entityClass); + } + + if ($this->entityClassVersion !== null) { + $params['entity_class_version'] = (string) $this->entityClassVersion; + } + + if (!empty($this->entityClassLevel)) { + $params['entity_class_level'] = PubNubUtil::urlEncode($this->entityClassLevel); + } + + return $params; + } + + public function sync(): PNDataSyncUsersResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncUsersResult + { + return PNDataSyncUsersResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/User/SetUser.php b/src/PubNub/Endpoints/DataSync/User/SetUser.php new file mode 100644 index 00000000..24f429dc --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/User/SetUser.php @@ -0,0 +1,82 @@ +id = $userId; + return $this; + } + + /** + * @return $this + */ + public function entityClassVersion(int $entityClassVersion): static + { + $this->entityClassVersion = $entityClassVersion; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + $this->validateId("userId"); + $this->validateClassVersion($this->entityClassVersion, "entityClassVersion"); + } + + /** + * @return string + */ + protected function buildData() + { + $data = ['entityClassVersion' => $this->entityClassVersion]; + + return $this->envelopeData($this->withPayloadAndStatus($data)); + } + + public function sync(): PNDataSyncUserResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncUserResult + { + return PNDataSyncUserResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/DataSync/User/UpdateUser.php b/src/PubNub/Endpoints/DataSync/User/UpdateUser.php new file mode 100644 index 00000000..8ed91200 --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/User/UpdateUser.php @@ -0,0 +1,67 @@ +id = $userId; + return $this; + } + + /** + * @throws PubNubValidationException + */ + protected function validateParams(): void + { + $this->validateSubscribeKey(); + $this->validateId("userId"); + $this->validatePatch(); + } + + /** + * @return string + */ + protected function buildData() + { + return $this->buildPatchData(); + } + + public function sync(): PNDataSyncUserResult + { + return parent::sync(); + } + + /** + * @param array $result + */ + protected function createResponse($result): PNDataSyncUserResult + { + return PNDataSyncUserResult::fromPayload($result); + } +} diff --git a/src/PubNub/Endpoints/Endpoint.php b/src/PubNub/Endpoints/Endpoint.php index 80dba331..94869cf6 100755 --- a/src/PubNub/Endpoints/Endpoint.php +++ b/src/PubNub/Endpoints/Endpoint.php @@ -239,7 +239,11 @@ protected function buildParams() . PubNubUtil::preparePamParams($params) . "\n"; - if (PNHttpMethod::POST == $httpMethod || PNHttpMethod::PATCH == $httpMethod) { + if ( + PNHttpMethod::POST == $httpMethod + || PNHttpMethod::PATCH == $httpMethod + || PNHttpMethod::PUT == $httpMethod + ) { $signedInput .= $this->buildData(); } @@ -444,22 +448,27 @@ public function parseResponse(ResponseInterface $response): PNEnvelope $response ); - if ($statusCode === 200) { + if ($statusCode >= 200 && $statusCode < 300) { $contents = $response->getBody()->getContents(); if (static::RESPONSE_IS_JSON) { - $parsedJSON = json_decode($contents, true); - - if (json_last_error()) { - return new PNEnvelope(null, $this->createStatus( - $statusCategory, - $response->getBody()->getContents(), - $responseInfo, - (new PubNubResponseParsingException()) - ->setResponseString($request->getBody()) - ->setDescription(json_last_error_msg()) - )); + // A successful DELETE answers 200 with no body at all, which json_decode() reports as a syntax error. + if (trim($contents) === '') { + $result = $this->createResponse([]); + } else { + $parsedJSON = json_decode($contents, true); + + if (json_last_error()) { + return new PNEnvelope(null, $this->createStatus( + $statusCategory, + $contents, + $responseInfo, + (new PubNubResponseParsingException()) + ->setResponseString($request->getBody()) + ->setDescription(json_last_error_msg()) + )); + } + $result = $this->createResponse($parsedJSON); } - $result = $this->createResponse($parsedJSON); } else { $result = $this->createResponse($contents); } diff --git a/src/PubNub/Enums/PNHttpMethod.php b/src/PubNub/Enums/PNHttpMethod.php index ae469f8c..cb8902be 100644 --- a/src/PubNub/Enums/PNHttpMethod.php +++ b/src/PubNub/Enums/PNHttpMethod.php @@ -2,11 +2,11 @@ namespace PubNub\Enums; - class PNHttpMethod { - const GET = "GET"; - const POST = "POST"; - const DELETE = "DELETE"; - const PATCH = "PATCH"; -} \ No newline at end of file + public const GET = "GET"; + public const POST = "POST"; + public const DELETE = "DELETE"; + public const PATCH = "PATCH"; + public const PUT = "PUT"; +} diff --git a/src/PubNub/Enums/PNOperationType.php b/src/PubNub/Enums/PNOperationType.php index 2d9cd666..2e27141d 100755 --- a/src/PubNub/Enums/PNOperationType.php +++ b/src/PubNub/Enums/PNOperationType.php @@ -77,4 +77,41 @@ class PNOperationType const PNManageMembersOperation = 56; const PNManageMembershipsOperation = 56; + + // DataSync + // entity + const PNDataSyncCreateEntityOperation = 57; + const PNDataSyncGetEntityOperation = 58; + const PNDataSyncGetEntitiesOperation = 59; + const PNDataSyncSetEntityOperation = 60; + const PNDataSyncUpdateEntityOperation = 61; + const PNDataSyncDeleteEntityOperation = 62; + // relationship + const PNDataSyncCreateRelationshipOperation = 63; + const PNDataSyncGetRelationshipOperation = 64; + const PNDataSyncGetRelationshipsOperation = 65; + const PNDataSyncSetRelationshipOperation = 66; + const PNDataSyncUpdateRelationshipOperation = 67; + const PNDataSyncDeleteRelationshipOperation = 68; + // user + const PNDataSyncCreateUserOperation = 69; + const PNDataSyncGetUserOperation = 70; + const PNDataSyncGetUsersOperation = 71; + const PNDataSyncSetUserOperation = 72; + const PNDataSyncUpdateUserOperation = 73; + const PNDataSyncDeleteUserOperation = 74; + // channel + const PNDataSyncCreateChannelOperation = 75; + const PNDataSyncGetChannelOperation = 76; + const PNDataSyncGetChannelsOperation = 77; + const PNDataSyncSetChannelOperation = 78; + const PNDataSyncUpdateChannelOperation = 79; + const PNDataSyncDeleteChannelOperation = 80; + // membership + const PNDataSyncCreateMembershipOperation = 81; + const PNDataSyncGetMembershipOperation = 82; + const PNDataSyncGetMembershipsOperation = 83; + const PNDataSyncSetMembershipOperation = 84; + const PNDataSyncUpdateMembershipOperation = 85; + const PNDataSyncDeleteMembershipOperation = 86; } diff --git a/src/PubNub/Managers/ListenerManager.php b/src/PubNub/Managers/ListenerManager.php index 75101e15..e0e2e6c9 100644 --- a/src/PubNub/Managers/ListenerManager.php +++ b/src/PubNub/Managers/ListenerManager.php @@ -2,9 +2,9 @@ namespace PubNub\Managers; - use PubNub\Callbacks\SubscribeCallback; use PubNub\Exceptions\PubNubUnsubscribeException; +use PubNub\Models\Consumer\DataSync\PNDataSyncEventResult; use PubNub\Models\Consumer\PubSub\PNMessageResult; use PubNub\Models\Consumer\PubSub\PNPresenceEventResult; use PubNub\Models\Consumer\PubSub\PNSignalMessageResult; @@ -90,4 +90,16 @@ public function announceSignal(PNSignalMessageResult $signal) $listener->signal($this->pubnub, $signal); } } -} \ No newline at end of file + + /** + * @param PNDataSyncEventResult $event + * @throws PubNubUnsubscribeException + * @return void + */ + public function announceDataSyncEvent(PNDataSyncEventResult $event) + { + foreach ($this->listeners as $listener) { + $listener->dataSyncEvent($this->pubnub, $event); + } + } +} diff --git a/src/PubNub/Managers/SubscriptionManager.php b/src/PubNub/Managers/SubscriptionManager.php index 64bd71f3..ed8314e3 100644 --- a/src/PubNub/Managers/SubscriptionManager.php +++ b/src/PubNub/Managers/SubscriptionManager.php @@ -3,6 +3,7 @@ namespace PubNub\Managers; use PubNub\Exceptions\PubNubResponseParsingException; +use PubNub\Models\Consumer\DataSync\PNDataSyncEventResult; use PubNub\Models\Consumer\PubSub\PNPresenceEventResult; use PubNub\Builders\DTO\SubscribeOperation; use PubNub\Builders\DTO\UnsubscribeOperation; @@ -263,6 +264,22 @@ protected function processIncomingPayload($message) $this->pubnub->getLogger()->debug("unable to parse payload on #processIncomingMessages"); } + if (MessageType::DATA_SYNC == $message->getMessageType()) { + $dataSyncEvent = PNDataSyncEventResult::fromPayload( + $extractedMessage, + $channel, + $subscriptionMatch, + $publishMetadata->getPublishTimetoken() + ); + + // Anything else carrying this message type is left to fall through to the regular + // message path rather than being swallowed here. + if ($dataSyncEvent !== null) { + $this->listenerManager->announceDataSyncEvent($dataSyncEvent); + return; + } + } + if (MessageType::SIGNAL == $message->getMessageType()) { $pnSignalResult = new PNSignalMessageResult( $extractedMessage, diff --git a/src/PubNub/Models/Consumer/AccessManager/PNAccessManagerTokenResult.php b/src/PubNub/Models/Consumer/AccessManager/PNAccessManagerTokenResult.php index d59201e9..63544237 100644 --- a/src/PubNub/Models/Consumer/AccessManager/PNAccessManagerTokenResult.php +++ b/src/PubNub/Models/Consumer/AccessManager/PNAccessManagerTokenResult.php @@ -21,7 +21,7 @@ class PNAccessManagerTokenResult /** @var object */ private $patterns; - /** @var object */ + /** @var array|object|null */ private $metadata; /** @var string */ @@ -30,6 +30,12 @@ class PNAccessManagerTokenResult /** @var string */ private $uuid; + /** @var PNDataSyncProjections|null */ + private $dataSyncProjections; + + /** @var bool */ + private $dataSyncProjectionsParsed = false; + final public function __construct( $version, $timestamp, @@ -90,6 +96,30 @@ public function getUuidResource($name) return $this->getResource('uuid', $name); } + /** + * @return Permissions|false false when the token grants nothing for that entity. + */ + public function getDataSyncEntityResource(string $name) + { + return $this->getResource('datasync:entities', $name); + } + + /** + * @return Permissions|false false when the token grants nothing for that relationship. + */ + public function getDataSyncRelationshipResource(string $name) + { + return $this->getResource('datasync:relationships', $name); + } + + /** + * @return Permissions|false false when the token grants nothing for that membership. + */ + public function getDataSyncMembershipResource(string $name) + { + return $this->getResource('datasync:memberships', $name); + } + private function getResource($type, $name) { if (isset($this->resources[$type][$name])) { @@ -114,6 +144,30 @@ public function getUuidPattern($name) return $this->getPattern('uuid', $name); } + /** + * @return Permissions|false false when the token holds no entity pattern with that name. + */ + public function getDataSyncEntityPattern(string $name) + { + return $this->getPattern('datasync:entities', $name); + } + + /** + * @return Permissions|false false when the token holds no relationship pattern with that name. + */ + public function getDataSyncRelationshipPattern(string $name) + { + return $this->getPattern('datasync:relationships', $name); + } + + /** + * @return Permissions|false false when the token holds no membership pattern with that name. + */ + public function getDataSyncMembershipPattern(string $name) + { + return $this->getPattern('datasync:memberships', $name); + } + private function getPattern($type, $name) { if (isset($this->patterns[$type][$name])) { @@ -128,6 +182,27 @@ public function getMetadata() return $this->metadata; } + /** + * DataSync field projections granted by this token, or null when it carries none. + */ + public function getDataSyncProjections(): ?PNDataSyncProjections + { + if (!$this->dataSyncProjectionsParsed) { + $this->dataSyncProjectionsParsed = true; + $meta = $this->metadata; + + if (is_object($meta)) { + $meta = (array) $meta; + } + + if (is_array($meta) && isset($meta['pn-projections'])) { + $this->dataSyncProjections = PNDataSyncProjections::fromArray($meta['pn-projections']); + } + } + + return $this->dataSyncProjections; + } + public function getSignature() { return str_replace(['/', '+'], ['-', '_'], base64_encode($this->sig)); @@ -174,7 +249,7 @@ public function toArray() } } - return [ + $result = [ 'version' => $this->version, 'timestamp' => $this->timestamp, 'ttl' => $this->ttl, @@ -183,5 +258,13 @@ public function toArray() 'signature' => $this->getSignature(), 'uuid' => $this->uuid, ]; + + $projections = $this->getDataSyncProjections(); + + if ($projections !== null) { + $result['projections'] = $projections->toArray(); + } + + return $result; } } diff --git a/src/PubNub/Models/Consumer/AccessManager/PNDataSyncProjectionScope.php b/src/PubNub/Models/Consumer/AccessManager/PNDataSyncProjectionScope.php new file mode 100644 index 00000000..79837fb6 --- /dev/null +++ b/src/PubNub/Models/Consumer/AccessManager/PNDataSyncProjectionScope.php @@ -0,0 +1,166 @@ + */ + private array $entities; + + /** @var array */ + private array $relationships; + + /** @var array */ + private array $memberships; + + /** + * @param array $entities + * @param array $relationships + * @param array $memberships + */ + public function __construct(array $entities = [], array $relationships = [], array $memberships = []) + { + $this->entities = $entities; + $this->relationships = $relationships; + $this->memberships = $memberships; + } + + /** + * Identifier to projection name, for entities. + * + * @return array + */ + public function getEntities(): array + { + return $this->entities; + } + + /** + * Identifier to projection name, for relationships. + * + * @return array + */ + public function getRelationships(): array + { + return $this->relationships; + } + + /** + * Identifier to projection name, for memberships. + * + * @return array + */ + public function getMemberships(): array + { + return $this->memberships; + } + + /** + * Projection name granted for one entity, or null when the token names no projection for it. + */ + public function getEntityProjection(string $id): ?string + { + return $this->entities[$id] ?? null; + } + + public function getRelationshipProjection(string $id): ?string + { + return $this->relationships[$id] ?? null; + } + + public function getMembershipProjection(string $id): ?string + { + return $this->memberships[$id] ?? null; + } + + public function isEmpty(): bool + { + return count($this->entities) === 0 + && count($this->relationships) === 0 + && count($this->memberships) === 0; + } + + /** + * @return array> + */ + public function toArray(): array + { + return [ + 'entities' => $this->entities, + 'relationships' => $this->relationships, + 'memberships' => $this->memberships, + ]; + } + + /** + * Malformed and unrecognised keys are skipped rather than raised; a token is allowed to carry + * scopes this SDK version does not know about yet. + * + * @param mixed $scope + */ + public static function fromArray($scope): self + { + $entities = []; + $relationships = []; + $memberships = []; + + if (!is_array($scope)) { + return new self(); + } + + foreach ($scope as $compositeKey => $projectionName) { + $parsed = self::splitCompositeKey((string) $compositeKey); + + if ($parsed === null) { + continue; + } + + [$type, $id] = $parsed; + + switch ($type) { + case 'entities': + $entities[$id] = (string) $projectionName; + break; + case 'relationships': + $relationships[$id] = (string) $projectionName; + break; + case 'memberships': + $memberships[$id] = (string) $projectionName; + break; + } + } + + return new self($entities, $relationships, $memberships); + } + + /** + * Splits "datasync:{type}:{id}" into its type and id, or returns null when it does not match. + * + * @return array{0: string, 1: string}|null + */ + private static function splitCompositeKey(string $compositeKey): ?array + { + if (strpos($compositeKey, self::PREFIX) !== 0) { + return null; + } + + $remainder = substr($compositeKey, strlen(self::PREFIX)); + $separator = strpos($remainder, ':'); + + // Both the type and the identifier have to be non-empty. + if ($separator === false || $separator === 0 || $separator === strlen($remainder) - 1) { + return null; + } + + return [substr($remainder, 0, $separator), substr($remainder, $separator + 1)]; + } +} diff --git a/src/PubNub/Models/Consumer/AccessManager/PNDataSyncProjections.php b/src/PubNub/Models/Consumer/AccessManager/PNDataSyncProjections.php new file mode 100644 index 00000000..ee1dc616 --- /dev/null +++ b/src/PubNub/Models/Consumer/AccessManager/PNDataSyncProjections.php @@ -0,0 +1,72 @@ +resources = $resources ?? new PNDataSyncProjectionScope(); + $this->patterns = $patterns ?? new PNDataSyncProjectionScope(); + } + + /** + * Projections keyed by exact resource identifier. + */ + public function getResources(): PNDataSyncProjectionScope + { + return $this->resources; + } + + /** + * Projections keyed by resource identifier pattern. + */ + public function getPatterns(): PNDataSyncProjectionScope + { + return $this->patterns; + } + + public function isEmpty(): bool + { + return $this->resources->isEmpty() && $this->patterns->isEmpty(); + } + + /** + * @return array>> + */ + public function toArray(): array + { + return [ + 'resources' => $this->resources->toArray(), + 'patterns' => $this->patterns->toArray(), + ]; + } + + /** + * @param mixed $projections + */ + public static function fromArray($projections): self + { + if (!is_array($projections)) { + return new self(); + } + + return new self( + PNDataSyncProjectionScope::fromArray($projections['res'] ?? null), + PNDataSyncProjectionScope::fromArray($projections['pat'] ?? null) + ); + } +} diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncChannelResult.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncChannelResult.php new file mode 100644 index 00000000..4c44c668 --- /dev/null +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncChannelResult.php @@ -0,0 +1,12 @@ + $payload + */ + public static function fromPayload(array $payload): self + { + return new self(); + } +} diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncEntitiesResult.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncEntitiesResult.php new file mode 100644 index 00000000..17dda29a --- /dev/null +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncEntitiesResult.php @@ -0,0 +1,69 @@ +data = $data; + $this->page = $page; + } + + /** + * @return PNDataSyncEntity[] + */ + public function getData(): array + { + return $this->data; + } + + public function getPage(): ?PNDataSyncPage + { + return $this->page; + } + + public function count(): int + { + return count($this->data); + } + + public function __toString(): string + { + return sprintf("count: %s, page: %s", count($this->data), $this->page); + } + + /** + * @param array $payload + */ + public static function fromPayload(array $payload): static + { + $items = []; + + if (array_key_exists("data", $payload) && is_array($payload["data"])) { + foreach ($payload["data"] as $item) { + if (is_array($item)) { + $items[] = PNDataSyncEntity::fromPayload($item); + } + } + } + + $meta = PNDataSyncValue::arrayOrNull($payload, "meta"); + + return new static( + $items, + $meta === null ? null : PNDataSyncPage::fromPayload($meta) + ); + } +} diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncEntity.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncEntity.php new file mode 100644 index 00000000..712b17e0 --- /dev/null +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncEntity.php @@ -0,0 +1,147 @@ +|null */ + protected ?array $payload; + + protected ?string $createdAt; + + protected ?string $updatedAt; + + protected ?string $eTag; + + protected ?string $expiresAt; + + /** + * @param array|null $payload + */ + public function __construct( + ?string $id = null, + ?string $entityClass = null, + ?int $entityClassVersion = null, + ?string $entityClassLevel = null, + ?string $status = null, + ?array $payload = null, + ?string $createdAt = null, + ?string $updatedAt = null, + ?string $eTag = null, + ?string $expiresAt = null + ) { + $this->id = $id; + $this->entityClass = $entityClass; + $this->entityClassVersion = $entityClassVersion; + $this->entityClassLevel = $entityClassLevel; + $this->status = $status; + $this->payload = $payload; + $this->createdAt = $createdAt; + $this->updatedAt = $updatedAt; + $this->eTag = $eTag; + $this->expiresAt = $expiresAt; + } + + public function getId(): ?string + { + return $this->id; + } + + public function getEntityClass(): ?string + { + return $this->entityClass; + } + + public function getEntityClassVersion(): ?int + { + return $this->entityClassVersion; + } + + public function getEntityClassLevel(): ?string + { + return $this->entityClassLevel; + } + + public function getStatus(): ?string + { + return $this->status; + } + + /** + * @return array|null + */ + public function getPayload(): ?array + { + return $this->payload; + } + + public function getCreatedAt(): ?string + { + return $this->createdAt; + } + + public function getUpdatedAt(): ?string + { + return $this->updatedAt; + } + + public function getETag(): ?string + { + return $this->eTag; + } + + public function getExpiresAt(): ?string + { + return $this->expiresAt; + } + + public function __toString(): string + { + return sprintf( + "id: %s, entityClass: %s, entityClassVersion: %s, status: %s, eTag: %s", + $this->id, + $this->entityClass, + $this->entityClassVersion, + $this->status, + $this->eTag + ); + } + + /** + * @param array $payload + */ + public static function fromPayload(array $payload): self + { + return new self( + PNDataSyncValue::stringOrNull($payload, "id"), + PNDataSyncValue::stringOrNull($payload, "entityClass"), + PNDataSyncValue::intOrNull($payload, "entityClassVersion"), + PNDataSyncValue::stringOrNull($payload, "entityClassLevel"), + PNDataSyncValue::stringOrNull($payload, "status"), + PNDataSyncValue::arrayOrNull($payload, "payload"), + PNDataSyncValue::stringOrNull($payload, "createdAt"), + PNDataSyncValue::stringOrNull($payload, "updatedAt"), + PNDataSyncValue::stringOrNull($payload, "eTag"), + PNDataSyncValue::stringOrNull($payload, "expiresAt") + ); + } +} diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncEntityResult.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncEntityResult.php new file mode 100644 index 00000000..c9511af2 --- /dev/null +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncEntityResult.php @@ -0,0 +1,49 @@ +data = $data; + } + + public function getData(): PNDataSyncEntity + { + return $this->data; + } + + public function getId(): ?string + { + return $this->data->getId(); + } + + /** + * Fingerprint to hand back through ifMatchesETag() on the next write. + */ + public function getETag(): ?string + { + return $this->data->getETag(); + } + + public function __toString(): string + { + return (string) $this->data; + } + + /** + * @param array $payload + */ + public static function fromPayload(array $payload): static + { + $data = PNDataSyncValue::arrayOrNull($payload, "data") ?? []; + + return new static(PNDataSyncEntity::fromPayload($data)); + } +} diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncEventResult.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncEventResult.php new file mode 100644 index 00000000..e1eca859 --- /dev/null +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncEventResult.php @@ -0,0 +1,274 @@ +version = $version; + $this->event = $event; + $this->source = $source; + $this->type = $type; + $this->className = $className; + $this->classVersion = $classVersion; + $this->entity = $entity; + $this->relationship = $relationship; + $this->id = $id; + $this->deletedAt = $deletedAt; + $this->channel = $channel; + $this->subscription = $subscription; + $this->timetoken = $timetoken; + } + + /** + * Schema version of the event envelope itself, e.g. "3.0". + */ + public function getVersion(): ?string + { + return $this->version; + } + + /** + * One of "create", "update" or "delete". + */ + public function getEvent(): ?string + { + return $this->event; + } + + /** + * Always "data-sync"; events from other sources never reach a listener. + */ + public function getSource(): ?string + { + return $this->source; + } + + /** + * Either "entity" or "relationship". + */ + public function getType(): ?string + { + return $this->type; + } + + public function getClassName(): ?string + { + return $this->className; + } + + public function getClassVersion(): ?int + { + return $this->classVersion; + } + + /** + * The changed entity, or null for a delete event or a relationship event. + */ + public function getEntity(): ?PNDataSyncEntity + { + return $this->entity; + } + + /** + * The changed relationship, or null for a delete event or an entity event. + */ + public function getRelationship(): ?PNDataSyncRelationship + { + return $this->relationship; + } + + public function getId(): ?string + { + return $this->id; + } + + public function getDeletedAt(): ?string + { + return $this->deletedAt; + } + + public function getChannel(): ?string + { + return $this->channel; + } + + public function getSubscription(): ?string + { + return $this->subscription; + } + + public function getTimetoken(): ?string + { + return $this->timetoken; + } + + public function __toString(): string + { + return sprintf( + "event: %s, type: %s, className: %s, classVersion: %s, id: %s", + $this->event, + $this->type, + $this->className, + $this->classVersion, + $this->id + ); + } + + /** + * Builds an event from a subscribe payload, or returns null when the payload is not a DataSync event. + * + * @param mixed $payload + */ + public static function fromPayload( + $payload, + ?string $channel = null, + ?string $subscription = null, + ?string $timetoken = null + ): ?self { + if (!is_array($payload) || !array_key_exists('metadata', $payload)) { + return null; + } + + $metadata = $payload['metadata']; + + if (!is_array($metadata)) { + return null; + } + + // Message type 5 is not exclusive to DataSync, so unrelated traffic has to be filtered out. + if (PNDataSyncValue::stringOrNull($metadata, 'source') !== self::SOURCE) { + return null; + } + + $version = PNDataSyncValue::stringOrNull($payload, 'version'); + $event = PNDataSyncValue::stringOrNull($metadata, 'event'); + $type = PNDataSyncValue::stringOrNull($metadata, 'type'); + $className = self::leafClassName(PNDataSyncValue::stringOrNull($metadata, 'className')); + $classVersion = PNDataSyncValue::intOrNull($metadata, 'classVersion'); + + $data = PNDataSyncValue::arrayOrNull($payload, 'data') ?? []; + $id = PNDataSyncValue::stringOrNull($data, 'id'); + + if ($event === self::EVENT_DELETE) { + return new self( + $version, + $event, + self::SOURCE, + $type, + $className, + $classVersion, + null, + null, + $id, + PNDataSyncValue::stringOrNull($data, 'deletedAt'), + $channel, + $subscription, + $timetoken + ); + } + + $entity = null; + $relationship = null; + + // The class name and version live in the metadata rather than in the record itself. + if ($type === self::TYPE_ENTITY) { + $entity = PNDataSyncEntity::fromPayload(array_merge($data, [ + 'entityClass' => $className, + 'entityClassVersion' => $classVersion, + ])); + } elseif ($type === self::TYPE_RELATIONSHIP) { + $relationship = PNDataSyncRelationship::fromPayload(array_merge($data, [ + 'relationshipClass' => $className, + 'relationshipClassVersion' => $classVersion, + ])); + } + + return new self( + $version, + $event, + self::SOURCE, + $type, + $className, + $classVersion, + $entity, + $relationship, + $id, + null, + $channel, + $subscription, + $timetoken + ); + } + + /** + * Class names arrive colon-delimited with their inherited classes ("Base:vehicle"); only the + * most derived one identifies the record. + */ + private static function leafClassName(?string $className): ?string + { + if ($className === null || $className === '') { + return $className; + } + + $segments = explode(':', $className); + + return end($segments); + } +} diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncMembership.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncMembership.php new file mode 100644 index 00000000..0cf48601 --- /dev/null +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncMembership.php @@ -0,0 +1,157 @@ +|null */ + protected ?array $payload; + + protected ?string $createdAt; + + protected ?string $updatedAt; + + protected ?string $eTag; + + protected ?string $expiresAt; + + /** + * @param array|null $payload + */ + public function __construct( + ?string $id = null, + ?string $channelId = null, + ?string $userId = null, + ?string $relationshipClass = null, + ?int $relationshipClassVersion = null, + ?string $status = null, + ?array $payload = null, + ?string $createdAt = null, + ?string $updatedAt = null, + ?string $eTag = null, + ?string $expiresAt = null + ) { + $this->id = $id; + $this->channelId = $channelId; + $this->userId = $userId; + $this->relationshipClass = $relationshipClass; + $this->relationshipClassVersion = $relationshipClassVersion; + $this->status = $status; + $this->payload = $payload; + $this->createdAt = $createdAt; + $this->updatedAt = $updatedAt; + $this->eTag = $eTag; + $this->expiresAt = $expiresAt; + } + + public function getId(): ?string + { + return $this->id; + } + + public function getChannelId(): ?string + { + return $this->channelId; + } + + public function getUserId(): ?string + { + return $this->userId; + } + + /** + * The predefined membership class the record belongs to, or a subclass of it. + */ + public function getRelationshipClass(): ?string + { + return $this->relationshipClass; + } + + public function getRelationshipClassVersion(): ?int + { + return $this->relationshipClassVersion; + } + + public function getStatus(): ?string + { + return $this->status; + } + + /** + * @return array|null + */ + public function getPayload(): ?array + { + return $this->payload; + } + + public function getCreatedAt(): ?string + { + return $this->createdAt; + } + + public function getUpdatedAt(): ?string + { + return $this->updatedAt; + } + + public function getETag(): ?string + { + return $this->eTag; + } + + public function getExpiresAt(): ?string + { + return $this->expiresAt; + } + + public function __toString(): string + { + return sprintf( + "id: %s, channelId: %s, userId: %s, status: %s, eTag: %s", + $this->id, + $this->channelId, + $this->userId, + $this->status, + $this->eTag + ); + } + + /** + * @param array $payload + */ + public static function fromPayload(array $payload): self + { + return new self( + PNDataSyncValue::stringOrNull($payload, "id"), + PNDataSyncValue::stringOrNull($payload, "channelId"), + PNDataSyncValue::stringOrNull($payload, "userId"), + PNDataSyncValue::stringOrNull($payload, "relationshipClass"), + PNDataSyncValue::intOrNull($payload, "relationshipClassVersion"), + PNDataSyncValue::stringOrNull($payload, "status"), + PNDataSyncValue::arrayOrNull($payload, "payload"), + PNDataSyncValue::stringOrNull($payload, "createdAt"), + PNDataSyncValue::stringOrNull($payload, "updatedAt"), + PNDataSyncValue::stringOrNull($payload, "eTag"), + PNDataSyncValue::stringOrNull($payload, "expiresAt") + ); + } +} diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncMembershipResult.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncMembershipResult.php new file mode 100644 index 00000000..1dc42e5d --- /dev/null +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncMembershipResult.php @@ -0,0 +1,49 @@ +data = $data; + } + + public function getData(): PNDataSyncMembership + { + return $this->data; + } + + public function getId(): ?string + { + return $this->data->getId(); + } + + /** + * Fingerprint to hand back through ifMatchesETag() on the next write. + */ + public function getETag(): ?string + { + return $this->data->getETag(); + } + + public function __toString(): string + { + return (string) $this->data; + } + + /** + * @param array $payload + */ + public static function fromPayload(array $payload): static + { + $data = PNDataSyncValue::arrayOrNull($payload, "data") ?? []; + + return new static(PNDataSyncMembership::fromPayload($data)); + } +} diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncMembershipsResult.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncMembershipsResult.php new file mode 100644 index 00000000..98b67196 --- /dev/null +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncMembershipsResult.php @@ -0,0 +1,69 @@ +data = $data; + $this->page = $page; + } + + /** + * @return PNDataSyncMembership[] + */ + public function getData(): array + { + return $this->data; + } + + public function getPage(): ?PNDataSyncPage + { + return $this->page; + } + + public function count(): int + { + return count($this->data); + } + + public function __toString(): string + { + return sprintf("count: %s, page: %s", count($this->data), $this->page); + } + + /** + * @param array $payload + */ + public static function fromPayload(array $payload): static + { + $items = []; + + if (array_key_exists("data", $payload) && is_array($payload["data"])) { + foreach ($payload["data"] as $item) { + if (is_array($item)) { + $items[] = PNDataSyncMembership::fromPayload($item); + } + } + } + + $meta = PNDataSyncValue::arrayOrNull($payload, "meta"); + + return new static( + $items, + $meta === null ? null : PNDataSyncPage::fromPayload($meta) + ); + } +} diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncPage.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncPage.php new file mode 100644 index 00000000..5c1c589f --- /dev/null +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncPage.php @@ -0,0 +1,67 @@ +nextCursor = $nextCursor; + $this->hasNext = $hasNext; + $this->limit = $limit; + } + + /** + * Opaque token to pass to cursor() on the next request; null once the last page is reached. + */ + public function getNextCursor(): ?string + { + return $this->nextCursor; + } + + public function hasNext(): bool + { + return $this->hasNext; + } + + /** + * The limit the server actually applied, which may differ from the requested one. + */ + public function getLimit(): ?int + { + return $this->limit; + } + + public function __toString(): string + { + return sprintf( + "nextCursor: %s, hasNext: %s, limit: %s", + $this->nextCursor, + $this->hasNext ? 'true' : 'false', + $this->limit + ); + } + + /** + * @param array $payload + */ + public static function fromPayload(array $payload): self + { + return new self( + PNDataSyncValue::stringOrNull($payload, "next_cursor"), + (bool) PNDataSyncValue::boolOrNull($payload, "has_next"), + PNDataSyncValue::intOrNull($payload, "limit") + ); + } +} diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncPatch.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncPatch.php new file mode 100644 index 00000000..2b777737 --- /dev/null +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncPatch.php @@ -0,0 +1,114 @@ +replace('/status', 'inactive') + * ->add('/payload/color', 'blue'); + */ +class PNDataSyncPatch +{ + public const OP_ADD = "add"; + public const OP_REMOVE = "remove"; + public const OP_REPLACE = "replace"; + public const OP_MOVE = "move"; + public const OP_COPY = "copy"; + public const OP_TEST = "test"; + + /** @var PNDataSyncPatchOperation[] */ + protected array $operations = []; + + /** + * @param mixed $value + * @return $this + */ + public function add(string $path, $value): static + { + $this->operations[] = new PNDataSyncPatchOperation(self::OP_ADD, $path, $value, true); + return $this; + } + + /** + * @return $this + */ + public function remove(string $path): static + { + $this->operations[] = new PNDataSyncPatchOperation(self::OP_REMOVE, $path); + return $this; + } + + /** + * @param mixed $value + * @return $this + */ + public function replace(string $path, $value): static + { + $this->operations[] = new PNDataSyncPatchOperation(self::OP_REPLACE, $path, $value, true); + return $this; + } + + /** + * @return $this + */ + public function move(string $from, string $path): static + { + $this->operations[] = new PNDataSyncPatchOperation(self::OP_MOVE, $path, null, false, $from); + return $this; + } + + /** + * @return $this + */ + public function copy(string $from, string $path): static + { + $this->operations[] = new PNDataSyncPatchOperation(self::OP_COPY, $path, null, false, $from); + return $this; + } + + /** + * Asserts the current value at $path before the rest of the patch is applied. + * + * @param mixed $value + * @return $this + */ + public function test(string $path, $value): static + { + $this->operations[] = new PNDataSyncPatchOperation(self::OP_TEST, $path, $value, true); + return $this; + } + + /** + * @return PNDataSyncPatchOperation[] + */ + public function getOperations(): array + { + return $this->operations; + } + + public function count(): int + { + return count($this->operations); + } + + /** + * @return array> + */ + public function toArray(): array + { + $result = []; + + foreach ($this->operations as $operation) { + $result[] = $operation->toArray(); + } + + return $result; + } +} diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncPatchOperation.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncPatchOperation.php new file mode 100644 index 00000000..0fe52595 --- /dev/null +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncPatchOperation.php @@ -0,0 +1,77 @@ +op = $op; + $this->path = $path; + $this->value = $value; + $this->hasValue = $hasValue; + $this->from = $from; + } + + public function getOp(): string + { + return $this->op; + } + + public function getPath(): string + { + return $this->path; + } + + /** + * @return mixed + */ + public function getValue() + { + return $this->value; + } + + public function getFrom(): ?string + { + return $this->from; + } + + /** + * @return array + */ + public function toArray(): array + { + $operation = [ + 'op' => $this->op, + 'path' => $this->path, + ]; + + // A null value is meaningful in JSON Patch, so presence is tracked separately from the value itself. + if ($this->hasValue) { + $operation['value'] = $this->value; + } + + if ($this->from !== null) { + $operation['from'] = $this->from; + } + + return $operation; + } +} diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncRelationship.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncRelationship.php new file mode 100644 index 00000000..b3f6f1be --- /dev/null +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncRelationship.php @@ -0,0 +1,151 @@ +|null */ + protected ?array $payload; + + protected ?string $createdAt; + + protected ?string $updatedAt; + + protected ?string $eTag; + + protected ?string $expiresAt; + + /** + * @param array|null $payload + */ + public function __construct( + ?string $id = null, + ?string $entityAId = null, + ?string $entityBId = null, + ?string $relationshipClass = null, + ?int $relationshipClassVersion = null, + ?string $status = null, + ?array $payload = null, + ?string $createdAt = null, + ?string $updatedAt = null, + ?string $eTag = null, + ?string $expiresAt = null + ) { + $this->id = $id; + $this->entityAId = $entityAId; + $this->entityBId = $entityBId; + $this->relationshipClass = $relationshipClass; + $this->relationshipClassVersion = $relationshipClassVersion; + $this->status = $status; + $this->payload = $payload; + $this->createdAt = $createdAt; + $this->updatedAt = $updatedAt; + $this->eTag = $eTag; + $this->expiresAt = $expiresAt; + } + + public function getId(): ?string + { + return $this->id; + } + + public function getEntityAId(): ?string + { + return $this->entityAId; + } + + public function getEntityBId(): ?string + { + return $this->entityBId; + } + + public function getRelationshipClass(): ?string + { + return $this->relationshipClass; + } + + public function getRelationshipClassVersion(): ?int + { + return $this->relationshipClassVersion; + } + + public function getStatus(): ?string + { + return $this->status; + } + + /** + * @return array|null + */ + public function getPayload(): ?array + { + return $this->payload; + } + + public function getCreatedAt(): ?string + { + return $this->createdAt; + } + + public function getUpdatedAt(): ?string + { + return $this->updatedAt; + } + + public function getETag(): ?string + { + return $this->eTag; + } + + public function getExpiresAt(): ?string + { + return $this->expiresAt; + } + + public function __toString(): string + { + return sprintf( + "id: %s, entityAId: %s, entityBId: %s, relationshipClass: %s, eTag: %s", + $this->id, + $this->entityAId, + $this->entityBId, + $this->relationshipClass, + $this->eTag + ); + } + + /** + * @param array $payload + */ + public static function fromPayload(array $payload): self + { + return new self( + PNDataSyncValue::stringOrNull($payload, "id"), + PNDataSyncValue::stringOrNull($payload, "entityAId"), + PNDataSyncValue::stringOrNull($payload, "entityBId"), + PNDataSyncValue::stringOrNull($payload, "relationshipClass"), + PNDataSyncValue::intOrNull($payload, "relationshipClassVersion"), + PNDataSyncValue::stringOrNull($payload, "status"), + PNDataSyncValue::arrayOrNull($payload, "payload"), + PNDataSyncValue::stringOrNull($payload, "createdAt"), + PNDataSyncValue::stringOrNull($payload, "updatedAt"), + PNDataSyncValue::stringOrNull($payload, "eTag"), + PNDataSyncValue::stringOrNull($payload, "expiresAt") + ); + } +} diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncRelationshipResult.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncRelationshipResult.php new file mode 100644 index 00000000..7d25cfcf --- /dev/null +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncRelationshipResult.php @@ -0,0 +1,49 @@ +data = $data; + } + + public function getData(): PNDataSyncRelationship + { + return $this->data; + } + + public function getId(): ?string + { + return $this->data->getId(); + } + + /** + * Fingerprint to hand back through ifMatchesETag() on the next write. + */ + public function getETag(): ?string + { + return $this->data->getETag(); + } + + public function __toString(): string + { + return (string) $this->data; + } + + /** + * @param array $payload + */ + public static function fromPayload(array $payload): static + { + $data = PNDataSyncValue::arrayOrNull($payload, "data") ?? []; + + return new static(PNDataSyncRelationship::fromPayload($data)); + } +} diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncRelationshipsResult.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncRelationshipsResult.php new file mode 100644 index 00000000..60651d88 --- /dev/null +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncRelationshipsResult.php @@ -0,0 +1,69 @@ +data = $data; + $this->page = $page; + } + + /** + * @return PNDataSyncRelationship[] + */ + public function getData(): array + { + return $this->data; + } + + public function getPage(): ?PNDataSyncPage + { + return $this->page; + } + + public function count(): int + { + return count($this->data); + } + + public function __toString(): string + { + return sprintf("count: %s, page: %s", count($this->data), $this->page); + } + + /** + * @param array $payload + */ + public static function fromPayload(array $payload): static + { + $items = []; + + if (array_key_exists("data", $payload) && is_array($payload["data"])) { + foreach ($payload["data"] as $item) { + if (is_array($item)) { + $items[] = PNDataSyncRelationship::fromPayload($item); + } + } + } + + $meta = PNDataSyncValue::arrayOrNull($payload, "meta"); + + return new static( + $items, + $meta === null ? null : PNDataSyncPage::fromPayload($meta) + ); + } +} diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncUserResult.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncUserResult.php new file mode 100644 index 00000000..8caa02e2 --- /dev/null +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncUserResult.php @@ -0,0 +1,12 @@ + $payload + */ + public static function stringOrNull(array $payload, string $key): ?string + { + if (!array_key_exists($key, $payload) || $payload[$key] === null) { + return null; + } + + return (string) $payload[$key]; + } + + /** + * @param array $payload + */ + public static function intOrNull(array $payload, string $key): ?int + { + if (!array_key_exists($key, $payload) || $payload[$key] === null) { + return null; + } + + return (int) $payload[$key]; + } + + /** + * @param array $payload + */ + public static function boolOrNull(array $payload, string $key): ?bool + { + if (!array_key_exists($key, $payload) || $payload[$key] === null) { + return null; + } + + return (bool) $payload[$key]; + } + + /** + * @param array $payload + * @return array|null + */ + public static function arrayOrNull(array $payload, string $key): ?array + { + if (!array_key_exists($key, $payload) || !is_array($payload[$key])) { + return null; + } + + return $payload[$key]; + } +} diff --git a/src/PubNub/Models/Server/MessageType.php b/src/PubNub/Models/Server/MessageType.php index 1f64ba39..d8f073a2 100644 --- a/src/PubNub/Models/Server/MessageType.php +++ b/src/PubNub/Models/Server/MessageType.php @@ -9,4 +9,5 @@ abstract class MessageType public const OBJECT = 2; public const MESSAGE_ACTION = 3; public const FILE_MESSAGE = 4; + public const DATA_SYNC = 5; } diff --git a/src/PubNub/PubNub.php b/src/PubNub/PubNub.php index 26f71656..2f7e8133 100644 --- a/src/PubNub/PubNub.php +++ b/src/PubNub/PubNub.php @@ -461,6 +461,16 @@ public function manageMemberships(): ManageMemberships return new ManageMemberships($this); } + /** + * Entry point for the DataSync operations. + * + * @return DataSync + */ + public function dataSync(): DataSync + { + return new DataSync($this); + } + /** * @return int */ diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 126ad3b1..b79f4470 100755 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -22,6 +22,12 @@ 'SUBSCRIBE_KEY', 'SUBSCRIBE_PAM_KEY', 'UUID_MOCK', + 'DATASYNC_SUBSCRIBE_KEY', + 'DATASYNC_PUBLISH_KEY', + 'DATASYNC_SECRET_KEY', + 'DATASYNC_ENTITY_CLASS', + 'DATASYNC_ENTITY_RELATIONSHIP', + 'DATASYNC_ORIGIN', ]; foreach ($requiredEnvKeys as $key) { diff --git a/tests/functional/dataSync/DataSyncGrantTokenTest.php b/tests/functional/dataSync/DataSyncGrantTokenTest.php new file mode 100644 index 00000000..8243614b --- /dev/null +++ b/tests/functional/dataSync/DataSyncGrantTokenTest.php @@ -0,0 +1,113 @@ +setSubscribeKey('sub-key'); + $config->setPublishKey('pub-key'); + $config->setSecretKey('secret-key'); + $config->setUuid('grant-token-uuid'); + $this->pubnub = new PubNub($config); + } + + /** + * @return array + */ + private function body(GrantToken $endpoint): array + { + $decoded = json_decode((string) $endpoint->buildData(), true); + $this->assertIsArray($decoded, 'buildData() should produce a JSON object'); + + return (array) $decoded; + } + + public function testDataSyncResourcesUseTheirOwnScopeKeys(): void + { + $body = $this->body( + $this->pubnub->grantToken() + ->ttl(60) + ->addDataSyncEntityResources(['vehicle-1' => ['read' => true, 'update' => true]]) + ->addDataSyncRelationshipResources(['rel-1' => ['read' => true]]) + ->addDataSyncMembershipResources(['mem-1' => ['read' => true, 'delete' => true]]) + ); + + $resources = $body['permissions']['resources']; + + $this->assertSame(65, $resources['datasync:entities']['vehicle-1']); + $this->assertSame(1, $resources['datasync:relationships']['rel-1']); + $this->assertSame(9, $resources['datasync:memberships']['mem-1']); + } + + public function testDataSyncPatternsUseTheirOwnScopeKeys(): void + { + $body = $this->body( + $this->pubnub->grantToken() + ->ttl(60) + ->addDataSyncEntityPatterns(['^vehicle-.*$' => ['read' => true]]) + ); + + $this->assertSame(1, $body['permissions']['patterns']['datasync:entities']['^vehicle-.*$']); + } + + public function testProjectionsAreFlattenedIntoMeta(): void + { + $body = $this->body( + $this->pubnub->grantToken() + ->ttl(60) + ->dataSyncProjections([ + 'resources' => [ + 'entities' => ['vehicle-1' => '__default__'], + 'memberships' => ['mem-1' => 'summary'], + ], + 'patterns' => [ + 'entities' => ['^vehicle-.*$' => 'public'], + ], + ]) + ); + + $projections = $body['permissions']['meta']['pn-projections']; + + $this->assertSame('__default__', $projections['res']['datasync:entities:vehicle-1']); + $this->assertSame('summary', $projections['res']['datasync:memberships:mem-1']); + $this->assertSame('public', $projections['pat']['datasync:entities:^vehicle-.*$']); + } + + public function testProjectionsDoNotClobberUserSuppliedMeta(): void + { + $body = $this->body( + $this->pubnub->grantToken() + ->ttl(60) + ->meta(['tenant' => 'acme']) + ->dataSyncProjections([ + 'resources' => ['entities' => ['vehicle-1' => '__default__']], + ]) + ); + + $meta = $body['permissions']['meta']; + + $this->assertSame('acme', $meta['tenant']); + $this->assertArrayHasKey('pn-projections', $meta); + } + + public function testNoProjectionsMeansNoMetaKey(): void + { + $body = $this->body($this->pubnub->grantToken()->ttl(60)); + + $this->assertArrayNotHasKey('meta', $body['permissions']); + } +} diff --git a/tests/functional/dataSync/DataSyncRequestTest.php b/tests/functional/dataSync/DataSyncRequestTest.php new file mode 100644 index 00000000..ecf72251 --- /dev/null +++ b/tests/functional/dataSync/DataSyncRequestTest.php @@ -0,0 +1,459 @@ +setSubscribeKey('sub-key'); + $config->setPublishKey('pub-key'); + $config->setUuid('datasync-request-uuid'); + $this->pubnub = new PubNub($config); + } + + /** + * @return array + */ + private function query(RequestInterface $request): array + { + $parsed = []; + parse_str($request->getUri()->getQuery(), $parsed); + return $parsed; + } + + public function testCreateEntityRequest(): void + { + $request = $this->pubnub->dataSync()->createEntity() + ->entityId('vehicle-1') + ->entityClass('vehicle') + ->entityClassVersion(1) + ->status('active') + ->payload(['make' => 'Toyota', 'model' => 'Camry']) + ->getRequest(); + + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('/v1/datasync/subkeys/sub-key/entities', $request->getUri()->getPath()); + $this->assertSame(self::ENTITY_MEDIA_TYPE, $request->getHeaderLine('Content-Type')); + $this->assertFalse($request->hasHeader('Accept')); + + $this->assertSame( + '{"data":{"id":"vehicle-1","entityClass":"vehicle","entityClassVersion":1,' + . '"status":"active","payload":{"make":"Toyota","model":"Camry"}}}', + (string) $request->getBody() + ); + } + + public function testCreateEntityOmitsUnsetOptionalFields(): void + { + $request = $this->pubnub->dataSync()->createEntity() + ->entityClass('vehicle') + ->entityClassVersion(1) + ->getRequest(); + + $this->assertSame( + '{"data":{"entityClass":"vehicle","entityClassVersion":1}}', + (string) $request->getBody() + ); + } + + public function testCreateEntityCanDisambiguateTheClassLevel(): void + { + $request = $this->pubnub->dataSync()->createEntity() + ->entityClass('vehicle') + ->entityClassVersion(1) + ->entityClassLevel('Global') + ->getRequest(); + + $this->assertSame( + '{"data":{"entityClass":"vehicle","entityClassVersion":1,"entityClassLevel":"Global"}}', + (string) $request->getBody() + ); + } + + public function testEmptyPayloadEncodesAsJsonObject(): void + { + $request = $this->pubnub->dataSync()->createEntity() + ->entityClass('vehicle') + ->entityClassVersion(1) + ->payload([]) + ->getRequest(); + + $this->assertStringContainsString('"payload":{}', (string) $request->getBody()); + } + + public function testGetEntityRequest(): void + { + $request = $this->pubnub->dataSync()->getEntity() + ->entityId('vehicle-1') + ->getRequest(); + + $this->assertSame('GET', $request->getMethod()); + $this->assertSame('/v1/datasync/subkeys/sub-key/entities/vehicle-1', $request->getUri()->getPath()); + $this->assertSame('', (string) $request->getBody()); + } + + public function testEntityIdIsUrlEncodedInThePath(): void + { + $request = $this->pubnub->dataSync()->getEntity() + ->entityId('vehicle 1/2') + ->getRequest(); + + $this->assertSame('/v1/datasync/subkeys/sub-key/entities/vehicle%201%2F2', $request->getUri()->getPath()); + } + + public function testGetEntitiesRequest(): void + { + $request = $this->pubnub->dataSync()->getEntities() + ->entityClass('vehicle') + ->entityClassVersion(2) + ->entityClassLevel('SubKey') + ->limit(50) + ->cursor('Y3Vyc29y') + ->filterFast("status == 'active'") + ->filter("payload.make == 'Toyota'") + ->sort(['createdAt' => 'desc', 'status']) + ->getRequest(); + + $query = $this->query($request); + + $this->assertSame('GET', $request->getMethod()); + $this->assertSame('/v1/datasync/subkeys/sub-key/entities', $request->getUri()->getPath()); + $this->assertSame('vehicle', $query['entity_class']); + $this->assertSame('2', $query['entity_class_version']); + $this->assertSame('SubKey', $query['entity_class_level']); + $this->assertSame('50', $query['limit']); + $this->assertSame('Y3Vyc29y', $query['cursor']); + $this->assertSame("status == 'active'", $query['filter_fast']); + $this->assertSame("payload.make == 'Toyota'", $query['filter']); + $this->assertSame('createdAt:desc,status', $query['sort']); + } + + public function testSetEntityRequest(): void + { + $request = $this->pubnub->dataSync()->setEntity() + ->entityId('vehicle-1') + ->entityClassVersion(1) + ->status('inactive') + ->payload(['make' => 'Toyota']) + ->ifMatchesETag('abc123') + ->getRequest(); + + $this->assertSame('PUT', $request->getMethod()); + $this->assertSame('/v1/datasync/subkeys/sub-key/entities/vehicle-1', $request->getUri()->getPath()); + $this->assertSame(self::ENTITY_MEDIA_TYPE, $request->getHeaderLine('Content-Type')); + $this->assertSame('abc123', $request->getHeaderLine('If-Match')); + + // entityClass is immutable and must never be sent on a replace. + $this->assertSame( + '{"data":{"entityClassVersion":1,"status":"inactive","payload":{"make":"Toyota"}}}', + (string) $request->getBody() + ); + } + + public function testUpdateEntityRequest(): void + { + $request = $this->pubnub->dataSync()->updateEntity() + ->entityId('vehicle-1') + ->patch( + (new PNDataSyncPatch()) + ->replace('/status', 'inactive') + ->add('/payload/color', 'blue') + ) + ->getRequest(); + + $this->assertSame('PATCH', $request->getMethod()); + $this->assertSame('/v1/datasync/subkeys/sub-key/entities/vehicle-1', $request->getUri()->getPath()); + $this->assertSame(self::PATCH_MEDIA_TYPE, $request->getHeaderLine('Content-Type')); + + // The patch document is a bare array rather than a {"data": ...} envelope. + // json_encode escapes forward slashes, so the JSON Pointers arrive as "\/status". + $this->assertSame( + '[{"op":"replace","path":"\/status","value":"inactive"},' + . '{"op":"add","path":"\/payload\/color","value":"blue"}]', + (string) $request->getBody() + ); + } + + public function testDeleteEntityRequest(): void + { + $request = $this->pubnub->dataSync()->deleteEntity() + ->entityId('vehicle-1') + ->ifMatchesETag('abc123') + ->getRequest(); + + $this->assertSame('DELETE', $request->getMethod()); + $this->assertSame('/v1/datasync/subkeys/sub-key/entities/vehicle-1', $request->getUri()->getPath()); + $this->assertSame('abc123', $request->getHeaderLine('If-Match')); + $this->assertSame('', (string) $request->getBody()); + + // The server answers 406 when a delete asks for a specific media type. + $this->assertFalse($request->hasHeader('Accept')); + $this->assertFalse($request->hasHeader('Content-Type')); + } + + public function testCreateRelationshipRequest(): void + { + $request = $this->pubnub->dataSync()->createRelationship() + ->entityAId('user-1') + ->entityBId('vehicle-1') + ->relationshipClass('owns') + ->relationshipClassVersion(1) + ->getRequest(); + + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('/v1/datasync/subkeys/sub-key/relationships', $request->getUri()->getPath()); + $this->assertSame(self::RELATIONSHIP_MEDIA_TYPE, $request->getHeaderLine('Content-Type')); + $this->assertSame( + '{"data":{"entityAId":"user-1","entityBId":"vehicle-1",' + . '"relationshipClass":"owns","relationshipClassVersion":1}}', + (string) $request->getBody() + ); + } + + public function testCreateRelationshipSendsTheClientSuppliedIdInTheBody(): void + { + $request = $this->pubnub->dataSync()->createRelationship() + ->relationshipId('rel-1') + ->entityAId('user-1') + ->entityBId('vehicle-1') + ->relationshipClass('owns') + ->relationshipClassVersion(1) + ->status('active') + ->payload(['role' => 'owner']) + ->getRequest(); + + $this->assertSame('/v1/datasync/subkeys/sub-key/relationships', $request->getUri()->getPath()); + $this->assertSame( + '{"data":{"id":"rel-1","entityAId":"user-1","entityBId":"vehicle-1",' + . '"relationshipClass":"owns","relationshipClassVersion":1,' + . '"status":"active","payload":{"role":"owner"}}}', + (string) $request->getBody() + ); + } + + public function testGetRelationshipRequest(): void + { + $request = $this->pubnub->dataSync()->getRelationship() + ->relationshipId('rel 1/2') + ->getRequest(); + + $this->assertSame('GET', $request->getMethod()); + $this->assertSame('/v1/datasync/subkeys/sub-key/relationships/rel%201%2F2', $request->getUri()->getPath()); + $this->assertSame('', (string) $request->getBody()); + } + + public function testGetRelationshipsRequest(): void + { + $request = $this->pubnub->dataSync()->getRelationships() + ->relationshipClass('owns') + ->relationshipClassVersion(2) + ->entityAId('user-1') + ->entityBId('vehicle-1') + ->limit(50) + ->cursor('Y3Vyc29y') + ->filterFast("status == 'active'") + ->filter("payload.role == 'owner'") + ->sort(['createdAt' => 'desc']) + ->getRequest(); + + $query = $this->query($request); + + $this->assertSame('GET', $request->getMethod()); + $this->assertSame('/v1/datasync/subkeys/sub-key/relationships', $request->getUri()->getPath()); + $this->assertSame('owns', $query['relationship_class']); + $this->assertSame('2', $query['relationship_class_version']); + $this->assertSame('user-1', $query['entity_a_id']); + $this->assertSame('vehicle-1', $query['entity_b_id']); + $this->assertSame('50', $query['limit']); + $this->assertSame('Y3Vyc29y', $query['cursor']); + $this->assertSame("status == 'active'", $query['filter_fast']); + $this->assertSame("payload.role == 'owner'", $query['filter']); + $this->assertSame('createdAt:desc', $query['sort']); + } + + public function testSetRelationshipRequest(): void + { + $request = $this->pubnub->dataSync()->setRelationship() + ->relationshipId('rel-1') + ->relationshipClassVersion(1) + ->status('updated') + ->payload(['role' => 'admin']) + ->ifMatchesETag('abc123') + ->getRequest(); + + $this->assertSame('PUT', $request->getMethod()); + $this->assertSame('/v1/datasync/subkeys/sub-key/relationships/rel-1', $request->getUri()->getPath()); + $this->assertSame(self::RELATIONSHIP_MEDIA_TYPE, $request->getHeaderLine('Content-Type')); + $this->assertSame('abc123', $request->getHeaderLine('If-Match')); + + // The class and the two linked entities are immutable, so a replace never sends them. + $this->assertSame( + '{"data":{"relationshipClassVersion":1,"status":"updated","payload":{"role":"admin"}}}', + (string) $request->getBody() + ); + } + + public function testUpdateRelationshipRequest(): void + { + $request = $this->pubnub->dataSync()->updateRelationship() + ->relationshipId('rel-1') + ->ifMatchesETag('abc123') + ->patch( + (new PNDataSyncPatch()) + ->replace('/status', 'patched') + ->add('/payload/patchedField', 'hello') + ) + ->getRequest(); + + $this->assertSame('PATCH', $request->getMethod()); + $this->assertSame('/v1/datasync/subkeys/sub-key/relationships/rel-1', $request->getUri()->getPath()); + $this->assertSame(self::PATCH_MEDIA_TYPE, $request->getHeaderLine('Content-Type')); + $this->assertSame('abc123', $request->getHeaderLine('If-Match')); + $this->assertSame( + '[{"op":"replace","path":"\/status","value":"patched"},' + . '{"op":"add","path":"\/payload\/patchedField","value":"hello"}]', + (string) $request->getBody() + ); + } + + public function testDeleteRelationshipRequest(): void + { + $request = $this->pubnub->dataSync()->deleteRelationship() + ->relationshipId('rel-1') + ->ifMatchesETag('abc123') + ->getRequest(); + + $this->assertSame('DELETE', $request->getMethod()); + $this->assertSame('/v1/datasync/subkeys/sub-key/relationships/rel-1', $request->getUri()->getPath()); + $this->assertSame('abc123', $request->getHeaderLine('If-Match')); + $this->assertSame('', (string) $request->getBody()); + $this->assertFalse($request->hasHeader('Accept')); + $this->assertFalse($request->hasHeader('Content-Type')); + } + + public function testCreateUserRequest(): void + { + $request = $this->pubnub->dataSync()->createUser() + ->userId('user-1') + ->entityClassVersion(1) + ->payload(['name' => 'Alice']) + ->getRequest(); + + $this->assertSame('/v1/datasync/subkeys/sub-key/users', $request->getUri()->getPath()); + $this->assertSame( + 'application/vnd.pubnub.objects.user+json;version=1', + $request->getHeaderLine('Content-Type') + ); + + // entityClass is left out so the server applies the predefined "User" class. + $this->assertSame( + '{"data":{"id":"user-1","entityClassVersion":1,"payload":{"name":"Alice"}}}', + (string) $request->getBody() + ); + } + + public function testGetUsersOmitsEntityClassWhenUnset(): void + { + $request = $this->pubnub->dataSync()->getUsers()->limit(10)->getRequest(); + + $query = $this->query($request); + + $this->assertSame('/v1/datasync/subkeys/sub-key/users', $request->getUri()->getPath()); + $this->assertArrayNotHasKey('entity_class', $query); + $this->assertSame('10', $query['limit']); + } + + public function testGetUsersSendsEntityClassWhenNarrowedToASubclass(): void + { + $request = $this->pubnub->dataSync()->getUsers() + ->entityClass('Employee') + ->entityClassVersion(2) + ->getRequest(); + + $query = $this->query($request); + + $this->assertSame('Employee', $query['entity_class']); + $this->assertSame('2', $query['entity_class_version']); + } + + public function testGetChannelsSendsEntityClassWhenNarrowedToASubclass(): void + { + $request = $this->pubnub->dataSync()->getChannels() + ->entityClass('PrivateChannel') + ->getRequest(); + + $this->assertSame('PrivateChannel', $this->query($request)['entity_class']); + } + + public function testCreateChannelRequest(): void + { + $request = $this->pubnub->dataSync()->createChannel() + ->channelId('channel-1') + ->entityClassVersion(1) + ->getRequest(); + + $this->assertSame('/v1/datasync/subkeys/sub-key/channels', $request->getUri()->getPath()); + $this->assertSame( + 'application/vnd.pubnub.objects.channel+json;version=1', + $request->getHeaderLine('Content-Type') + ); + } + + public function testCreateMembershipRequest(): void + { + $request = $this->pubnub->dataSync()->createMembership() + ->channelId('channel-1') + ->userId('user-1') + ->relationshipClassVersion(1) + ->getRequest(); + + $this->assertSame('/v1/datasync/subkeys/sub-key/memberships', $request->getUri()->getPath()); + $this->assertSame( + 'application/vnd.pubnub.objects.membership+json;version=1', + $request->getHeaderLine('Content-Type') + ); + + // Membership is a predefined relationship class, so no relationshipClass is sent. + $this->assertSame( + '{"data":{"channelId":"channel-1","userId":"user-1","relationshipClassVersion":1}}', + (string) $request->getBody() + ); + } + + public function testGetMembershipsFiltersByUserAndChannel(): void + { + $request = $this->pubnub->dataSync()->getMemberships() + ->userId('user-1') + ->channelId('channel-1') + ->relationshipClassVersion(3) + ->getRequest(); + + $query = $this->query($request); + + $this->assertSame('user-1', $query['user_id']); + $this->assertSame('channel-1', $query['channel_id']); + $this->assertSame('3', $query['relationship_class_version']); + $this->assertArrayNotHasKey('relationship_class', $query); + } +} diff --git a/tests/integrational/dataSync/DataSyncEndpointTest.php b/tests/integrational/dataSync/DataSyncEndpointTest.php new file mode 100644 index 00000000..04ab86d4 --- /dev/null +++ b/tests/integrational/dataSync/DataSyncEndpointTest.php @@ -0,0 +1,386 @@ +client = new PsrStubClient(); + $this->pubnub_demo->setClient($this->client); + $this->pubnub_demo->getConfiguration()->setUuid(self::UUID); + } + + /** + * @param array $extraQuery + */ + private function stub(string $path, array $extraQuery = []): PsrStub + { + return $this->client->stubFor($path)->withQuery(array_merge([ + 'pnsdk' => $this->pubnub_demo->getSdkFullName(), + 'uuid' => self::UUID, + ], $extraQuery)); + } + + /** + * @param array $data + */ + private function json(array $data): string + { + return (string) json_encode($data); + } + + public function testCreateEntityAcceptsHttp201(): void + { + $this->stub('/v1/datasync/subkeys/demo/entities') + ->setResponseStatus(201) + ->setResponseBody($this->json([ + 'data' => [ + 'id' => 'vehicle-1', + 'entityClass' => 'vehicle', + 'entityClassVersion' => 1, + 'status' => 'active', + 'payload' => ['make' => 'Toyota'], + 'createdAt' => '2026-08-21T10:00:00Z', + 'eTag' => 'abc123', + ], + ])); + + $result = $this->pubnub_demo->dataSync()->createEntity() + ->entityId('vehicle-1') + ->entityClass('vehicle') + ->entityClassVersion(1) + ->status('active') + ->payload(['make' => 'Toyota']) + ->sync(); + + $this->assertInstanceOf(PNDataSyncEntityResult::class, $result); + $this->assertSame('vehicle-1', $result->getId()); + $this->assertSame('abc123', $result->getETag()); + $this->assertSame('vehicle', $result->getData()->getEntityClass()); + $this->assertSame(1, $result->getData()->getEntityClassVersion()); + $this->assertSame(['make' => 'Toyota'], $result->getData()->getPayload()); + } + + public function testDeleteEntityAcceptsEmptyBody(): void + { + $this->stub('/v1/datasync/subkeys/demo/entities/vehicle-1')->setResponseBody(''); + + $result = $this->pubnub_demo->dataSync()->deleteEntity() + ->entityId('vehicle-1') + ->sync(); + + $this->assertInstanceOf(PNDataSyncDeleteResult::class, $result); + $this->assertTrue($result->isSuccess()); + } + + public function testGetEntity(): void + { + $this->stub('/v1/datasync/subkeys/demo/entities/vehicle-1') + ->setResponseBody($this->json([ + 'data' => [ + 'id' => 'vehicle-1', + 'entityClass' => 'vehicle', + 'entityClassVersion' => 1, + 'payload' => ['make' => 'Toyota', 'model' => 'Camry'], + ], + ])); + + $result = $this->pubnub_demo->dataSync()->getEntity()->entityId('vehicle-1')->sync(); + + $this->assertSame('vehicle-1', $result->getId()); + $this->assertSame(['make' => 'Toyota', 'model' => 'Camry'], $result->getData()->getPayload()); + } + + public function testGetEntitiesParsesPagination(): void + { + $this->stub('/v1/datasync/subkeys/demo/entities', ['entity_class' => 'vehicle', 'limit' => '2']) + ->setResponseBody($this->json([ + 'data' => [ + ['id' => 'vehicle-1', 'entityClass' => 'vehicle'], + ['id' => 'vehicle-2', 'entityClass' => 'vehicle'], + ], + 'meta' => ['next_cursor' => 'Y3Vyc29y', 'has_next' => true, 'limit' => 2], + ])); + + $result = $this->pubnub_demo->dataSync()->getEntities() + ->entityClass('vehicle') + ->limit(2) + ->sync(); + + $this->assertInstanceOf(PNDataSyncEntitiesResult::class, $result); + $this->assertSame(2, $result->count()); + $this->assertSame('vehicle-1', $result->getData()[0]->getId()); + $this->assertSame('Y3Vyc29y', $result->getPage()->getNextCursor()); + $this->assertTrue($result->getPage()->hasNext()); + $this->assertSame(2, $result->getPage()->getLimit()); + } + + public function testGetEntitiesOnLastPage(): void + { + $this->stub('/v1/datasync/subkeys/demo/entities', ['entity_class' => 'vehicle']) + ->setResponseBody($this->json([ + 'data' => [], + 'meta' => ['has_next' => false], + ])); + + $result = $this->pubnub_demo->dataSync()->getEntities()->entityClass('vehicle')->sync(); + + $this->assertSame(0, $result->count()); + $this->assertFalse($result->getPage()->hasNext()); + $this->assertNull($result->getPage()->getNextCursor()); + } + + public function testSetEntity(): void + { + $this->stub('/v1/datasync/subkeys/demo/entities/vehicle-1') + ->setResponseBody($this->json([ + 'data' => [ + 'id' => 'vehicle-1', + 'status' => 'inactive', + 'eTag' => 'def456', + ], + ])); + + $result = $this->pubnub_demo->dataSync()->setEntity() + ->entityId('vehicle-1') + ->entityClassVersion(1) + ->status('inactive') + ->ifMatchesETag('abc123') + ->sync(); + + $this->assertSame('inactive', $result->getData()->getStatus()); + $this->assertSame('def456', $result->getETag()); + } + + public function testUpdateEntity(): void + { + $this->stub('/v1/datasync/subkeys/demo/entities/vehicle-1') + ->setResponseBody($this->json([ + 'data' => ['id' => 'vehicle-1', 'payload' => ['color' => 'blue']], + ])); + + $result = $this->pubnub_demo->dataSync()->updateEntity() + ->entityId('vehicle-1') + ->patch((new PNDataSyncPatch())->add('/payload/color', 'blue')) + ->sync(); + + $this->assertSame(['color' => 'blue'], $result->getData()->getPayload()); + } + + public function testCreateRelationship(): void + { + $this->stub('/v1/datasync/subkeys/demo/relationships') + ->setResponseStatus(201) + ->setResponseBody($this->json([ + 'data' => [ + 'id' => 'rel-1', + 'entityAId' => 'user-1', + 'entityBId' => 'vehicle-1', + 'relationshipClass' => 'owns', + 'relationshipClassVersion' => 1, + ], + ])); + + $result = $this->pubnub_demo->dataSync()->createRelationship() + ->entityAId('user-1') + ->entityBId('vehicle-1') + ->relationshipClass('owns') + ->relationshipClassVersion(1) + ->sync(); + + $this->assertInstanceOf(PNDataSyncRelationshipResult::class, $result); + $this->assertSame('user-1', $result->getData()->getEntityAId()); + $this->assertSame('vehicle-1', $result->getData()->getEntityBId()); + } + + public function testGetRelationship(): void + { + $this->stub('/v1/datasync/subkeys/demo/relationships/rel-1') + ->setResponseBody($this->json([ + 'data' => [ + 'id' => 'rel-1', + 'entityAId' => 'user-1', + 'entityBId' => 'vehicle-1', + 'relationshipClass' => 'owns', + 'relationshipClassVersion' => 1, + 'status' => 'active', + 'payload' => ['role' => 'owner'], + 'createdAt' => '2026-08-21T10:00:00Z', + 'eTag' => 'abc123', + ], + ])); + + $result = $this->pubnub_demo->dataSync()->getRelationship()->relationshipId('rel-1')->sync(); + + $this->assertSame('rel-1', $result->getId()); + $this->assertSame('abc123', $result->getETag()); + $this->assertSame('owns', $result->getData()->getRelationshipClass()); + $this->assertSame(1, $result->getData()->getRelationshipClassVersion()); + $this->assertSame('active', $result->getData()->getStatus()); + $this->assertSame(['role' => 'owner'], $result->getData()->getPayload()); + $this->assertSame('2026-08-21T10:00:00Z', $result->getData()->getCreatedAt()); + } + + public function testGetRelationshipsParsesPagination(): void + { + $this->stub( + '/v1/datasync/subkeys/demo/relationships', + ['relationship_class' => 'owns', 'entity_a_id' => 'user-1', 'limit' => '2'] + )->setResponseBody($this->json([ + 'data' => [ + ['id' => 'rel-1', 'entityAId' => 'user-1', 'entityBId' => 'vehicle-1'], + ['id' => 'rel-2', 'entityAId' => 'user-1', 'entityBId' => 'vehicle-2'], + ], + 'meta' => ['next_cursor' => 'Y3Vyc29y', 'has_next' => true, 'limit' => 2], + ])); + + $result = $this->pubnub_demo->dataSync()->getRelationships() + ->relationshipClass('owns') + ->entityAId('user-1') + ->limit(2) + ->sync(); + + $this->assertInstanceOf(PNDataSyncRelationshipsResult::class, $result); + $this->assertSame(2, $result->count()); + $this->assertSame('rel-1', $result->getData()[0]->getId()); + $this->assertSame('vehicle-2', $result->getData()[1]->getEntityBId()); + $this->assertSame('Y3Vyc29y', $result->getPage()->getNextCursor()); + $this->assertTrue($result->getPage()->hasNext()); + } + + public function testSetRelationship(): void + { + $this->stub('/v1/datasync/subkeys/demo/relationships/rel-1') + ->setResponseBody($this->json([ + 'data' => [ + 'id' => 'rel-1', + 'entityAId' => 'user-1', + 'entityBId' => 'vehicle-1', + 'relationshipClass' => 'owns', + 'relationshipClassVersion' => 1, + 'status' => 'updated', + 'payload' => ['role' => 'admin'], + 'eTag' => 'def456', + ], + ])); + + $result = $this->pubnub_demo->dataSync()->setRelationship() + ->relationshipId('rel-1') + ->relationshipClassVersion(1) + ->status('updated') + ->payload(['role' => 'admin']) + ->ifMatchesETag('abc123') + ->sync(); + + $this->assertSame('updated', $result->getData()->getStatus()); + $this->assertSame(['role' => 'admin'], $result->getData()->getPayload()); + $this->assertSame('def456', $result->getETag()); + } + + public function testUpdateRelationship(): void + { + $this->stub('/v1/datasync/subkeys/demo/relationships/rel-1') + ->setResponseBody($this->json([ + 'data' => [ + 'id' => 'rel-1', + 'status' => 'patched', + 'payload' => ['role' => 'admin', 'patchedField' => 'hello'], + ], + ])); + + $result = $this->pubnub_demo->dataSync()->updateRelationship() + ->relationshipId('rel-1') + ->patch( + (new PNDataSyncPatch()) + ->replace('/status', 'patched') + ->add('/payload/patchedField', 'hello') + ) + ->sync(); + + $this->assertSame('patched', $result->getData()->getStatus()); + $this->assertSame(['role' => 'admin', 'patchedField' => 'hello'], $result->getData()->getPayload()); + } + + public function testDeleteRelationshipAcceptsEmptyBody(): void + { + $this->stub('/v1/datasync/subkeys/demo/relationships/rel-1')->setResponseBody(''); + + $result = $this->pubnub_demo->dataSync()->deleteRelationship()->relationshipId('rel-1')->sync(); + + $this->assertInstanceOf(PNDataSyncDeleteResult::class, $result); + $this->assertTrue($result->isSuccess()); + } + + public function testCreateMembership(): void + { + $this->stub('/v1/datasync/subkeys/demo/memberships') + ->setResponseStatus(201) + ->setResponseBody($this->json([ + 'data' => [ + 'id' => 'mem-1', + 'channelId' => 'channel-1', + 'userId' => 'user-1', + 'relationshipClass' => 'Membership', + 'relationshipClassVersion' => 1, + ], + ])); + + $result = $this->pubnub_demo->dataSync()->createMembership() + ->channelId('channel-1') + ->userId('user-1') + ->relationshipClassVersion(1) + ->sync(); + + $this->assertInstanceOf(PNDataSyncMembershipResult::class, $result); + $this->assertSame('channel-1', $result->getData()->getChannelId()); + $this->assertSame('user-1', $result->getData()->getUserId()); + $this->assertSame('Membership', $result->getData()->getRelationshipClass()); + $this->assertSame(1, $result->getData()->getRelationshipClassVersion()); + } + + public function testGetUsersFiltersByEntityClass(): void + { + $this->stub('/v1/datasync/subkeys/demo/users', ['entity_class' => 'Employee']) + ->setResponseBody($this->json([ + 'data' => [['id' => 'user-1', 'entityClass' => 'Employee']], + 'meta' => ['has_next' => false], + ])); + + $result = $this->pubnub_demo->dataSync()->getUsers()->entityClass('Employee')->sync(); + + $this->assertSame(1, $result->count()); + $this->assertSame('Employee', $result->getData()[0]->getEntityClass()); + } + + public function testDeleteMembershipAcceptsEmptyBody(): void + { + $this->stub('/v1/datasync/subkeys/demo/memberships/mem-1')->setResponseBody(''); + + $result = $this->pubnub_demo->dataSync()->deleteMembership()->membershipId('mem-1')->sync(); + + $this->assertTrue($result->isSuccess()); + } +} diff --git a/tests/integrational/dataSync/DataSyncLifecycleTest.php b/tests/integrational/dataSync/DataSyncLifecycleTest.php new file mode 100644 index 00000000..44793334 --- /dev/null +++ b/tests/integrational/dataSync/DataSyncLifecycleTest.php @@ -0,0 +1,255 @@ +entityClass = getenv('DATASYNC_ENTITY_CLASS') ?: ''; + $this->relationshipClass = getenv('DATASYNC_ENTITY_RELATIONSHIP') ?: ''; + + if ($subscribeKey === '' || $publishKey === '' || $this->entityClass === '') { + $this->markTestSkipped( + 'Set DATASYNC_SUBSCRIBE_KEY, DATASYNC_PUBLISH_KEY and DATASYNC_ENTITY_CLASS to run the ' + . 'DataSync lifecycle test against a live keyset.' + ); + } + + $config = new PNConfiguration(); + $config->setSubscribeKey($subscribeKey); + $config->setPublishKey($publishKey); + $config->setUuid('datasync-lifecycle-test'); + + $secretKey = getenv('DATASYNC_SECRET_KEY') ?: ''; + + if ($secretKey !== '') { + $config->setSecretKey($secretKey); + } + + if ($origin = getenv('DATASYNC_ORIGIN')) { + $config->setOrigin($origin); + } + + $this->pubnub = new PubNub($config); + } + + public function testEntityLifecycle(): void + { + $entityId = 'php-sdk-lifecycle-' . uniqid(); + + $created = $this->pubnub->dataSync()->createEntity() + ->entityId($entityId) + ->entityClass($this->entityClass) + ->entityClassVersion(1) + ->status('active') + ->payload(['make' => 'Toyota', 'model' => 'Camry']) + ->sync(); + + $this->assertSame($entityId, $created->getId()); + $this->assertNotEmpty($created->getETag()); + + try { + $fetched = $this->pubnub->dataSync()->getEntity()->entityId($entityId)->sync(); + + $this->assertSame($entityId, $fetched->getId()); + $this->assertSame('active', $fetched->getData()->getStatus()); + + $updated = $this->pubnub->dataSync()->updateEntity() + ->entityId($entityId) + ->ifMatchesETag($fetched->getETag()) + ->patch((new PNDataSyncPatch())->replace('/status', 'inactive')) + ->sync(); + + $this->assertSame('inactive', $updated->getData()->getStatus()); + + // The ETag moved on with the patch, so replaying the stale one must be refused. + $staleETagWasRejected = false; + + try { + $this->pubnub->dataSync()->updateEntity() + ->entityId($entityId) + ->ifMatchesETag($fetched->getETag()) + ->patch((new PNDataSyncPatch())->replace('/status', 'active')) + ->sync(); + } catch (PubNubServerException $exception) { + $staleETagWasRejected = true; + $this->assertSame(412, $exception->getStatusCode()); + } + + $this->assertTrue($staleETagWasRejected, 'a stale ETag must fail with 412'); + + // PUT replaces the record outright, so the model field written at create time is gone. + $replaced = $this->pubnub->dataSync()->setEntity() + ->entityId($entityId) + ->entityClassVersion(1) + ->ifMatchesETag($updated->getETag()) + ->status('archived') + ->payload(['make' => 'Honda']) + ->sync(); + + $this->assertSame('archived', $replaced->getData()->getStatus()); + $this->assertSame(['make' => 'Honda'], $replaced->getData()->getPayload()); + $this->assertNotEmpty($replaced->getETag()); + + $listed = $this->pubnub->dataSync()->getEntities() + ->entityClass($this->entityClass) + ->limit(100) + ->sync(); + + $this->assertNotNull($listed->getPage()); + } finally { + $deleted = $this->pubnub->dataSync()->deleteEntity()->entityId($entityId)->sync(); + $this->assertTrue($deleted->isSuccess()); + } + } + + /** + * Same round trip for a relationship, which additionally proves that the two linked entities + * and the relationship class survive a full replacement while status and payload do not. + */ + public function testRelationshipLifecycle(): void + { + if ($this->relationshipClass === '') { + $this->markTestSkipped( + 'Set DATASYNC_ENTITY_RELATIONSHIP to a relationship class registered on the keyset to run the ' + . 'DataSync relationship lifecycle test.' + ); + } + + $entityAId = $this->createEntity(); + $entityBId = $this->createEntity(); + + try { + $created = $this->pubnub->dataSync()->createRelationship() + ->entityAId($entityAId) + ->entityBId($entityBId) + ->relationshipClass($this->relationshipClass) + ->relationshipClassVersion(1) + ->status('new') + ->payload(['role' => 'member', 'joinedAt' => '2025-01-01']) + ->sync(); + + $relationshipId = (string) $created->getId(); + + $this->assertNotSame('', $relationshipId); + $this->assertNotEmpty($created->getETag()); + + try { + $fetched = $this->pubnub->dataSync()->getRelationship() + ->relationshipId($relationshipId) + ->sync(); + + $this->assertSame($relationshipId, $fetched->getId()); + $this->assertSame('new', $fetched->getData()->getStatus()); + $this->assertSame($entityAId, $fetched->getData()->getEntityAId()); + $this->assertSame($entityBId, $fetched->getData()->getEntityBId()); + $this->assertSame($this->relationshipClass, $fetched->getData()->getRelationshipClass()); + + // PUT replaces the mutable fields outright, so joinedAt does not survive it, while + // the immutable class and entity links stay put even though PUT never sends them. + $replaced = $this->pubnub->dataSync()->setRelationship() + ->relationshipId($relationshipId) + ->relationshipClassVersion(1) + ->ifMatchesETag((string) $fetched->getETag()) + ->status('updated') + ->payload(['role' => 'admin']) + ->sync(); + + $this->assertSame('updated', $replaced->getData()->getStatus()); + $this->assertSame(['role' => 'admin'], $replaced->getData()->getPayload()); + $this->assertSame($entityAId, $replaced->getData()->getEntityAId()); + $this->assertSame($entityBId, $replaced->getData()->getEntityBId()); + $this->assertSame($this->relationshipClass, $replaced->getData()->getRelationshipClass()); + + $patched = $this->pubnub->dataSync()->updateRelationship() + ->relationshipId($relationshipId) + ->ifMatchesETag((string) $replaced->getETag()) + ->patch( + (new PNDataSyncPatch()) + ->replace('/status', 'patched') + ->add('/payload/patchedField', 'hello') + ) + ->sync(); + + $this->assertSame('patched', $patched->getData()->getStatus()); + + $verified = $this->pubnub->dataSync()->getRelationship() + ->relationshipId($relationshipId) + ->sync(); + + $this->assertSame('patched', $verified->getData()->getStatus()); + $this->assertSame( + ['role' => 'admin', 'patchedField' => 'hello'], + $verified->getData()->getPayload() + ); + + $listed = $this->pubnub->dataSync()->getRelationships() + ->relationshipClass($this->relationshipClass) + ->entityAId($entityAId) + ->limit(100) + ->sync(); + + $ids = array_map( + static fn($relationship) => $relationship->getId(), + $listed->getData() + ); + + $this->assertContains($relationshipId, $ids); + } finally { + $deleted = $this->pubnub->dataSync()->deleteRelationship() + ->relationshipId($relationshipId) + ->sync(); + $this->assertTrue($deleted->isSuccess()); + } + } finally { + $this->pubnub->dataSync()->deleteEntity()->entityId($entityAId)->sync(); + $this->pubnub->dataSync()->deleteEntity()->entityId($entityBId)->sync(); + } + } + + /** + * Creates a throwaway entity to hang a relationship off and returns its id. + */ + private function createEntity(): string + { + $entityId = 'php-sdk-lifecycle-' . uniqid(); + + $this->pubnub->dataSync()->createEntity() + ->entityId($entityId) + ->entityClass($this->entityClass) + ->entityClassVersion(1) + ->status('active') + ->payload(['name' => 'entity-' . $entityId]) + ->sync(); + + return $entityId; + } +} diff --git a/tests/unit/dataSync/DataSyncEventResultTest.php b/tests/unit/dataSync/DataSyncEventResultTest.php new file mode 100644 index 00000000..c3e0ae08 --- /dev/null +++ b/tests/unit/dataSync/DataSyncEventResultTest.php @@ -0,0 +1,141 @@ + $overrides + * @return array + */ + private function payload(array $overrides = []): array + { + return array_merge([ + 'version' => '3.0', + 'metadata' => [ + 'event' => 'create', + 'source' => 'data-sync', + 'type' => 'entity', + 'className' => 'Base:vehicle', + 'classVersion' => '1', + ], + 'data' => [ + 'id' => 'vehicle-1', + 'status' => 'active', + 'payload' => ['make' => 'Toyota'], + 'createdAt' => '2026-08-21T10:00:00Z', + 'eTag' => 'abc123', + ], + ], $overrides); + } + + public function testParsesEntityCreateEvent(): void + { + $event = PNDataSyncEventResult::fromPayload($this->payload(), 'vehicle-1', null, '17000000000000000'); + + $this->assertNotNull($event); + $this->assertSame('3.0', $event->getVersion()); + $this->assertSame('data-sync', $event->getSource()); + $this->assertSame('create', $event->getEvent()); + $this->assertSame('entity', $event->getType()); + $this->assertSame('vehicle-1', $event->getId()); + $this->assertSame('vehicle-1', $event->getChannel()); + $this->assertSame('17000000000000000', $event->getTimetoken()); + + $entity = $event->getEntity(); + $this->assertNotNull($entity); + $this->assertSame('active', $entity->getStatus()); + $this->assertSame(['make' => 'Toyota'], $entity->getPayload()); + $this->assertSame('abc123', $entity->getETag()); + } + + public function testKeepsOnlyTheMostDerivedClassName(): void + { + $event = PNDataSyncEventResult::fromPayload($this->payload()); + + $this->assertNotNull($event); + $this->assertSame('vehicle', $event->getClassName()); + $this->assertSame('vehicle', $event->getEntity()->getEntityClass()); + } + + public function testCastsClassVersionToInt(): void + { + $event = PNDataSyncEventResult::fromPayload($this->payload()); + + $this->assertNotNull($event); + $this->assertSame(1, $event->getClassVersion()); + $this->assertSame(1, $event->getEntity()->getEntityClassVersion()); + } + + public function testDeleteEventCarriesOnlyIdAndDeletedAt(): void + { + $event = PNDataSyncEventResult::fromPayload($this->payload([ + 'metadata' => [ + 'event' => 'delete', + 'source' => 'data-sync', + 'type' => 'entity', + 'className' => 'vehicle', + 'classVersion' => '1', + ], + 'data' => [ + 'id' => 'vehicle-1', + 'deletedAt' => '2026-08-21T11:00:00Z', + ], + ])); + + $this->assertNotNull($event); + $this->assertSame('delete', $event->getEvent()); + $this->assertSame('vehicle-1', $event->getId()); + $this->assertSame('2026-08-21T11:00:00Z', $event->getDeletedAt()); + $this->assertNull($event->getEntity()); + $this->assertNull($event->getRelationship()); + } + + public function testParsesRelationshipEvent(): void + { + $event = PNDataSyncEventResult::fromPayload($this->payload([ + 'metadata' => [ + 'event' => 'update', + 'source' => 'data-sync', + 'type' => 'relationship', + 'className' => 'owns', + 'classVersion' => '2', + ], + 'data' => [ + 'id' => 'rel-1', + 'entityAId' => 'user-1', + 'entityBId' => 'vehicle-1', + ], + ])); + + $this->assertNotNull($event); + $this->assertNull($event->getEntity()); + + $relationship = $event->getRelationship(); + $this->assertNotNull($relationship); + $this->assertSame('user-1', $relationship->getEntityAId()); + $this->assertSame('vehicle-1', $relationship->getEntityBId()); + $this->assertSame('owns', $relationship->getRelationshipClass()); + $this->assertSame(2, $relationship->getRelationshipClassVersion()); + } + + public function testIgnoresPayloadsFromOtherSources(): void + { + $payload = $this->payload(); + $payload['metadata']['source'] = 'something-else'; + + $this->assertNull(PNDataSyncEventResult::fromPayload($payload)); + } + + public function testIgnoresPayloadsWithoutMetadata(): void + { + $this->assertNull(PNDataSyncEventResult::fromPayload(['data' => ['id' => 'x']])); + $this->assertNull(PNDataSyncEventResult::fromPayload('plain string message')); + } +} diff --git a/tests/unit/dataSync/DataSyncPatchTest.php b/tests/unit/dataSync/DataSyncPatchTest.php new file mode 100644 index 00000000..94cbec35 --- /dev/null +++ b/tests/unit/dataSync/DataSyncPatchTest.php @@ -0,0 +1,64 @@ +replace('/status', 'inactive') + ->add('/payload/color', 'blue') + ->remove('/payload/obsolete'); + + $this->assertSame([ + ['op' => 'replace', 'path' => '/status', 'value' => 'inactive'], + ['op' => 'add', 'path' => '/payload/color', 'value' => 'blue'], + ['op' => 'remove', 'path' => '/payload/obsolete'], + ], $patch->toArray()); + } + + public function testMoveAndCopyCarryFrom(): void + { + $patch = (new PNDataSyncPatch()) + ->move('/payload/old', '/payload/new') + ->copy('/payload/new', '/payload/backup'); + + $this->assertSame([ + ['op' => 'move', 'path' => '/payload/new', 'from' => '/payload/old'], + ['op' => 'copy', 'path' => '/payload/backup', 'from' => '/payload/new'], + ], $patch->toArray()); + } + + public function testNullValueIsKeptRatherThanDropped(): void + { + $patch = (new PNDataSyncPatch())->replace('/payload/color', null); + + $this->assertSame([ + ['op' => 'replace', 'path' => '/payload/color', 'value' => null], + ], $patch->toArray()); + } + + public function testEncodesAsBareJsonArray(): void + { + $patch = (new PNDataSyncPatch())->test('/status', 'active'); + + $this->assertSame( + '[{"op":"test","path":"/status","value":"active"}]', + json_encode($patch->toArray(), JSON_UNESCAPED_SLASHES) + ); + } + + public function testCountsOperations(): void + { + $patch = (new PNDataSyncPatch())->add('/payload/a', 1)->add('/payload/b', 2); + + $this->assertSame(2, $patch->count()); + } +} diff --git a/tests/unit/dataSync/DataSyncTokenParseTest.php b/tests/unit/dataSync/DataSyncTokenParseTest.php new file mode 100644 index 00000000..e1b1fe42 --- /dev/null +++ b/tests/unit/dataSync/DataSyncTokenParseTest.php @@ -0,0 +1,206 @@ + $overrides + */ + private function token(array $overrides = []): PNAccessManagerTokenResult + { + return PNAccessManagerTokenResult::fromArray(array_merge([ + 'v' => 2, + 't' => 1755000000, + 'ttl' => 60, + 'res' => [ + 'chan' => [], + 'grp' => [], + 'usr' => [], + 'spc' => [], + 'uuid' => [], + 'datasync:entities' => ['vehicle-1' => 65], + 'datasync:relationships' => ['rel-1' => 1], + 'datasync:memberships' => ['mem-1' => 9], + ], + 'pat' => [ + 'chan' => [], + 'grp' => [], + 'usr' => [], + 'spc' => [], + 'uuid' => [], + 'datasync:entities' => ['^vehicle-.*$' => 1], + 'datasync:relationships' => [], + 'datasync:memberships' => [], + ], + 'meta' => [], + 'uuid' => 'my-uuid', + 'sig' => 'signature-bytes', + ], $overrides)); + } + + /** + * @param Permissions|false $granted + */ + private function granted($granted): Permissions + { + $this->assertInstanceOf(Permissions::class, $granted); + + /** @var Permissions $granted */ + return $granted; + } + + private function projections(PNAccessManagerTokenResult $token): PNDataSyncProjections + { + $projections = $token->getDataSyncProjections(); + $this->assertNotNull($projections); + + /** @var PNDataSyncProjections $projections */ + return $projections; + } + + public function testDataSyncResourcePermissionsAreDecoded(): void + { + $token = $this->token(); + + $entity = $this->granted($token->getDataSyncEntityResource('vehicle-1')); + $this->assertTrue($entity->hasRead()); + $this->assertTrue($entity->hasUpdate()); + $this->assertFalse($entity->hasDelete()); + $this->assertFalse($entity->hasWrite()); + + $this->assertTrue($this->granted($token->getDataSyncRelationshipResource('rel-1'))->hasRead()); + + $membership = $this->granted($token->getDataSyncMembershipResource('mem-1')); + $this->assertTrue($membership->hasRead()); + $this->assertTrue($membership->hasDelete()); + } + + public function testDataSyncPatternPermissionsAreDecoded(): void + { + $pattern = $this->granted($this->token()->getDataSyncEntityPattern('^vehicle-.*$')); + + $this->assertTrue($pattern->hasRead()); + } + + public function testUnknownDataSyncResourceReturnsFalse(): void + { + $token = $this->token(); + + $this->assertFalse($token->getDataSyncEntityResource('vehicle-2')); + $this->assertFalse($token->getDataSyncRelationshipPattern('^rel-.*$')); + } + + public function testProjectionsAreSplitBackIntoResourceFamilies(): void + { + $token = $this->token([ + 'meta' => [ + 'pn-projections' => [ + 'res' => [ + 'datasync:entities:vehicle-1' => '__default__', + 'datasync:relationships:rel-1' => 'brief', + 'datasync:memberships:mem-1' => 'summary', + ], + 'pat' => [ + 'datasync:entities:^vehicle-.*$' => 'public', + ], + ], + ], + ]); + + $projections = $this->projections($token); + + $resources = $projections->getResources(); + $this->assertSame(['vehicle-1' => '__default__'], $resources->getEntities()); + $this->assertSame(['rel-1' => 'brief'], $resources->getRelationships()); + $this->assertSame(['mem-1' => 'summary'], $resources->getMemberships()); + $this->assertSame('__default__', $resources->getEntityProjection('vehicle-1')); + + $patterns = $projections->getPatterns(); + $this->assertSame('public', $patterns->getEntityProjection('^vehicle-.*$')); + $this->assertSame([], $patterns->getMemberships()); + } + + public function testProjectionIdentifiersMayContainColons(): void + { + $scope = PNDataSyncProjectionScope::fromArray([ + 'datasync:memberships:user:U1:channel:C1' => 'summary', + ]); + + $this->assertSame('summary', $scope->getMembershipProjection('user:U1:channel:C1')); + } + + public function testMalformedAndUnknownProjectionKeysAreSkipped(): void + { + $scope = PNDataSyncProjectionScope::fromArray([ + 'datasync:entities:vehicle-1' => '__default__', + 'datasync:entities:' => 'no-identifier', + 'datasync:vehicle-1' => 'no-type', + 'datasync:widgets:widget-1' => 'unknown-family', + 'chan:my-channel' => 'not-datasync', + ]); + + $this->assertSame(['vehicle-1' => '__default__'], $scope->getEntities()); + $this->assertSame([], $scope->getRelationships()); + $this->assertSame([], $scope->getMemberships()); + } + + public function testTokenWithoutProjectionsReturnsNull(): void + { + $this->assertNull($this->token()->getDataSyncProjections()); + } + + public function testProjectionScopeMissingFromMetaIsEmptyRatherThanNull(): void + { + $projections = $this->projections($this->token([ + 'meta' => [ + 'pn-projections' => [ + 'res' => ['datasync:entities:vehicle-1' => '__default__'], + ], + ], + ])); + + $this->assertFalse($projections->getResources()->isEmpty()); + $this->assertTrue($projections->getPatterns()->isEmpty()); + } + + public function testProjectionsAppearInToArrayOnlyWhenPresent(): void + { + $this->assertArrayNotHasKey('projections', $this->token()->toArray()); + + $withProjections = $this->token([ + 'meta' => [ + 'pn-projections' => [ + 'res' => ['datasync:entities:vehicle-1' => '__default__'], + ], + ], + ])->toArray(); + + $this->assertSame( + ['vehicle-1' => '__default__'], + $withProjections['projections']['resources']['entities'] + ); + } + + public function testDataSyncScopesSurviveToArray(): void + { + $resources = $this->token()->toArray()['resources']; + + $this->assertTrue($resources['datasync:entities']['vehicle-1']['read']); + $this->assertTrue($resources['datasync:entities']['vehicle-1']['update']); + $this->assertFalse($resources['datasync:entities']['vehicle-1']['join']); + } +} diff --git a/tests/unit/dataSync/DataSyncValidationTest.php b/tests/unit/dataSync/DataSyncValidationTest.php new file mode 100644 index 00000000..4d68def6 --- /dev/null +++ b/tests/unit/dataSync/DataSyncValidationTest.php @@ -0,0 +1,332 @@ +setSubscribeKey('demo'); + $config->setPublishKey('demo'); + $config->setUuid('datasync-validation-uuid'); + $this->pubnub = new PubNub($config); + } + + private function validate(Endpoint $endpoint): void + { + $method = new \ReflectionMethod($endpoint, 'validateParams'); + $method->invoke($endpoint); + } + + public function testCreateEntityRequiresEntityClass(): void + { + $endpoint = $this->pubnub->dataSync()->createEntity()->entityClassVersion(1); + + $this->expectException(PubNubValidationException::class); + $this->expectExceptionMessage('entityClass missing'); + $this->validate($endpoint); + } + + public function testCreateEntityRejectsClassVersionBelowOne(): void + { + $endpoint = $this->pubnub->dataSync()->createEntity() + ->entityClass('vehicle') + ->entityClassVersion(0); + + $this->expectException(PubNubValidationException::class); + $this->expectExceptionMessage('entityClassVersion must be greater than or equal to 1'); + $this->validate($endpoint); + } + + public function testCreateEntityWithoutIdIsValid(): void + { + $endpoint = $this->pubnub->dataSync()->createEntity() + ->entityClass('vehicle') + ->entityClassVersion(1); + + $this->validate($endpoint); + $this->assertTrue(true, 'the server generates an id when none is supplied'); + } + + public function testGetEntityRequiresId(): void + { + $endpoint = $this->pubnub->dataSync()->getEntity(); + + $this->expectException(PubNubValidationException::class); + $this->expectExceptionMessage('entityId missing'); + $this->validate($endpoint); + } + + public function testGetEntitiesRequiresEntityClass(): void + { + $endpoint = $this->pubnub->dataSync()->getEntities(); + + $this->expectException(PubNubValidationException::class); + $this->expectExceptionMessage('entityClass missing'); + $this->validate($endpoint); + } + + public function testSetEntityRequiresId(): void + { + $endpoint = $this->pubnub->dataSync()->setEntity()->entityClassVersion(1); + + $this->expectException(PubNubValidationException::class); + $this->expectExceptionMessage('entityId missing'); + $this->validate($endpoint); + } + + public function testSetEntityRequiresClassVersion(): void + { + $endpoint = $this->pubnub->dataSync()->setEntity()->entityId('e-1'); + + $this->expectException(PubNubValidationException::class); + $this->expectExceptionMessage('entityClassVersion must be greater than or equal to 1'); + $this->validate($endpoint); + } + + public function testUpdateEntityRequiresId(): void + { + $endpoint = $this->pubnub->dataSync()->updateEntity() + ->patch((new PNDataSyncPatch())->replace('/status', 'inactive')); + + $this->expectException(PubNubValidationException::class); + $this->expectExceptionMessage('entityId missing'); + $this->validate($endpoint); + } + + public function testUpdateEntityRequiresAtLeastOneOperation(): void + { + $endpoint = $this->pubnub->dataSync()->updateEntity() + ->entityId('e-1') + ->patch(new PNDataSyncPatch()); + + $this->expectException(PubNubValidationException::class); + $this->expectExceptionMessage('patch operations missing'); + $this->validate($endpoint); + } + + public function testUpdateEntityRejectsReplaceWithoutValue(): void + { + $endpoint = $this->pubnub->dataSync()->updateEntity() + ->entityId('e-1') + ->patch([['op' => 'replace', 'path' => '/status']]); + + $this->expectException(PubNubValidationException::class); + $this->expectExceptionMessage('patch operation "replace" requires a value'); + $this->validate($endpoint); + } + + public function testUpdateEntityRejectsMoveWithoutFrom(): void + { + $endpoint = $this->pubnub->dataSync()->updateEntity() + ->entityId('e-1') + ->patch([['op' => 'move', 'path' => '/payload/b']]); + + $this->expectException(PubNubValidationException::class); + $this->expectExceptionMessage('patch operation "move" requires a from path'); + $this->validate($endpoint); + } + + public function testUpdateEntityAcceptsRemoveWithoutValue(): void + { + $endpoint = $this->pubnub->dataSync()->updateEntity() + ->entityId('e-1') + ->patch((new PNDataSyncPatch())->remove('/payload/obsolete')); + + $this->validate($endpoint); + $this->assertTrue(true, 'remove carries neither value nor from'); + } + + public function testDeleteEntityRequiresId(): void + { + $endpoint = $this->pubnub->dataSync()->deleteEntity(); + + $this->expectException(PubNubValidationException::class); + $this->expectExceptionMessage('entityId missing'); + $this->validate($endpoint); + } + + public function testCreateRelationshipRequiresBothEntityIds(): void + { + $endpoint = $this->pubnub->dataSync()->createRelationship() + ->entityBId('b') + ->relationshipClass('owns') + ->relationshipClassVersion(1); + + $this->expectException(PubNubValidationException::class); + $this->expectExceptionMessage('entityAId missing'); + $this->validate($endpoint); + } + + public function testCreateRelationshipRequiresRelationshipClass(): void + { + $endpoint = $this->pubnub->dataSync()->createRelationship() + ->entityAId('a') + ->entityBId('b') + ->relationshipClassVersion(1); + + $this->expectException(PubNubValidationException::class); + $this->expectExceptionMessage('relationshipClass missing'); + $this->validate($endpoint); + } + + public function testCreateRelationshipRejectsClassVersionBelowOne(): void + { + $endpoint = $this->pubnub->dataSync()->createRelationship() + ->entityAId('a') + ->entityBId('b') + ->relationshipClass('owns') + ->relationshipClassVersion(0); + + $this->expectException(PubNubValidationException::class); + $this->expectExceptionMessage('relationshipClassVersion must be greater than or equal to 1'); + $this->validate($endpoint); + } + + public function testCreateRelationshipWithoutIdIsValid(): void + { + $endpoint = $this->pubnub->dataSync()->createRelationship() + ->entityAId('a') + ->entityBId('b') + ->relationshipClass('owns') + ->relationshipClassVersion(1); + + $this->validate($endpoint); + $this->assertTrue(true, 'the server generates an id when none is supplied'); + } + + public function testGetRelationshipRequiresId(): void + { + $endpoint = $this->pubnub->dataSync()->getRelationship(); + + $this->expectException(PubNubValidationException::class); + $this->expectExceptionMessage('relationshipId missing'); + $this->validate($endpoint); + } + + public function testGetRelationshipsRequiresRelationshipClass(): void + { + $endpoint = $this->pubnub->dataSync()->getRelationships(); + + $this->expectException(PubNubValidationException::class); + $this->expectExceptionMessage('relationshipClass missing'); + $this->validate($endpoint); + } + + public function testGetRelationshipsWithOnlyTheClassIsValid(): void + { + $endpoint = $this->pubnub->dataSync()->getRelationships()->relationshipClass('owns'); + + $this->validate($endpoint); + $this->assertTrue(true, 'entityAId and entityBId only narrow an otherwise valid listing'); + } + + public function testSetRelationshipRequiresId(): void + { + $endpoint = $this->pubnub->dataSync()->setRelationship()->relationshipClassVersion(1); + + $this->expectException(PubNubValidationException::class); + $this->expectExceptionMessage('relationshipId missing'); + $this->validate($endpoint); + } + + public function testSetRelationshipRequiresClassVersion(): void + { + $endpoint = $this->pubnub->dataSync()->setRelationship()->relationshipId('rel-1'); + + $this->expectException(PubNubValidationException::class); + $this->expectExceptionMessage('relationshipClassVersion must be greater than or equal to 1'); + $this->validate($endpoint); + } + + public function testUpdateRelationshipRequiresId(): void + { + $endpoint = $this->pubnub->dataSync()->updateRelationship() + ->patch((new PNDataSyncPatch())->replace('/status', 'patched')); + + $this->expectException(PubNubValidationException::class); + $this->expectExceptionMessage('relationshipId missing'); + $this->validate($endpoint); + } + + public function testUpdateRelationshipRequiresAtLeastOneOperation(): void + { + $endpoint = $this->pubnub->dataSync()->updateRelationship() + ->relationshipId('rel-1') + ->patch(new PNDataSyncPatch()); + + $this->expectException(PubNubValidationException::class); + $this->expectExceptionMessage('patch operations missing'); + $this->validate($endpoint); + } + + public function testDeleteRelationshipRequiresId(): void + { + $endpoint = $this->pubnub->dataSync()->deleteRelationship(); + + $this->expectException(PubNubValidationException::class); + $this->expectExceptionMessage('relationshipId missing'); + $this->validate($endpoint); + } + + public function testCreateMembershipRequiresChannelId(): void + { + $endpoint = $this->pubnub->dataSync()->createMembership() + ->userId('u-1') + ->relationshipClassVersion(1); + + $this->expectException(PubNubValidationException::class); + $this->expectExceptionMessage('channelId missing'); + $this->validate($endpoint); + } + + public function testCreateMembershipRequiresUserId(): void + { + $endpoint = $this->pubnub->dataSync()->createMembership() + ->channelId('c-1') + ->relationshipClassVersion(1); + + $this->expectException(PubNubValidationException::class); + $this->expectExceptionMessage('userId missing'); + $this->validate($endpoint); + } + + public function testGetMembershipsNeedsNoFilters(): void + { + $endpoint = $this->pubnub->dataSync()->getMemberships(); + + $this->validate($endpoint); + $this->assertTrue(true, 'listing every membership of the key is a valid request'); + } + + public function testCreateUserDoesNotRequireEntityClass(): void + { + $endpoint = $this->pubnub->dataSync()->createUser()->entityClassVersion(1); + + $this->validate($endpoint); + $this->assertTrue(true, 'entityClass defaults to "User" server-side'); + } + + public function testDeleteChannelRequiresId(): void + { + $endpoint = $this->pubnub->dataSync()->deleteChannel(); + + $this->expectException(PubNubValidationException::class); + $this->expectExceptionMessage('channelId missing'); + $this->validate($endpoint); + } +} From b6b12f6fe8c3eefb2aa370ee7a73a95b371495b4 Mon Sep 17 00:00:00 2001 From: "PUBNUB\\jakub.grzesiowski" Date: Mon, 14 Sep 2026 19:20:09 +0200 Subject: [PATCH 2/8] Internal publishes fix + tests --- .../DataSync/PNDataSyncEventResult.php | 96 +++-- tests/helpers/DataSyncEventCollector.php | 145 +++++++ .../dataSync/DataSyncEventTest.php | 366 ++++++++++++++++++ .../dataSync/DataSyncSubscribeTest.php | 203 ++++++++++ .../unit/dataSync/DataSyncEventResultTest.php | 170 +++++++- 5 files changed, 951 insertions(+), 29 deletions(-) create mode 100644 tests/helpers/DataSyncEventCollector.php create mode 100644 tests/integrational/dataSync/DataSyncEventTest.php create mode 100644 tests/integrational/dataSync/DataSyncSubscribeTest.php diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncEventResult.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncEventResult.php index e1eca859..3c18066b 100644 --- a/src/PubNub/Models/Consumer/DataSync/PNDataSyncEventResult.php +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncEventResult.php @@ -7,7 +7,8 @@ * * The server publishes one of these whenever a record the client is subscribed to is created, * updated or deleted. The channel carrying the event is the record's identifier, or - * __{projection}__{id} when a non-default projection is in play. + * __{projection}__{id} when a non-default projection is in play. Relationship and membership + * events are published on the channels of the two records they link rather than on their own id. */ class PNDataSyncEventResult { @@ -18,7 +19,16 @@ class PNDataSyncEventResult public const EVENT_DELETE = "delete"; public const TYPE_ENTITY = "entity"; + public const TYPE_USER = "user"; + public const TYPE_CHANNEL = "channel"; public const TYPE_RELATIONSHIP = "relationship"; + public const TYPE_MEMBERSHIP = "membership"; + + /** The predefined classes are shaped like a generic entity. */ + private const ENTITY_TYPES = [self::TYPE_ENTITY, self::TYPE_USER, self::TYPE_CHANNEL]; + + /** A membership is a relationship between a Channel and a User. */ + private const RELATIONSHIP_TYPES = [self::TYPE_RELATIONSHIP, self::TYPE_MEMBERSHIP]; protected ?string $version; @@ -32,10 +42,14 @@ class PNDataSyncEventResult protected ?int $classVersion; + protected ?string $classLevel; + protected ?PNDataSyncEntity $entity; protected ?PNDataSyncRelationship $relationship; + protected ?PNDataSyncMembership $membership; + protected ?string $id; protected ?string $deletedAt; @@ -53,8 +67,10 @@ public function __construct( ?string $type = null, ?string $className = null, ?int $classVersion = null, + ?string $classLevel = null, ?PNDataSyncEntity $entity = null, ?PNDataSyncRelationship $relationship = null, + ?PNDataSyncMembership $membership = null, ?string $id = null, ?string $deletedAt = null, ?string $channel = null, @@ -67,8 +83,10 @@ public function __construct( $this->type = $type; $this->className = $className; $this->classVersion = $classVersion; + $this->classLevel = $classLevel; $this->entity = $entity; $this->relationship = $relationship; + $this->membership = $membership; $this->id = $id; $this->deletedAt = $deletedAt; $this->channel = $channel; @@ -101,7 +119,7 @@ public function getSource(): ?string } /** - * Either "entity" or "relationship". + * One of "entity", "user", "channel", "relationship" or "membership". */ public function getType(): ?string { @@ -119,7 +137,17 @@ public function getClassVersion(): ?int } /** - * The changed entity, or null for a delete event or a relationship event. + * Either "Global" or "SubKey", telling a built-in class apart from a developer-defined one + * that happens to carry the same name. + */ + public function getClassLevel(): ?string + { + return $this->classLevel; + } + + /** + * The changed record of an entity, user or channel event. Null on a delete and on the + * relationship-shaped types. */ public function getEntity(): ?PNDataSyncEntity { @@ -127,13 +155,26 @@ public function getEntity(): ?PNDataSyncEntity } /** - * The changed relationship, or null for a delete event or an entity event. + * The changed record of a relationship or membership event. Null on a delete and on the + * entity-shaped types. + * + * A membership is reported here too, with the channel as entity A and the user as entity B; + * getMembership() returns the same record under its own names. */ public function getRelationship(): ?PNDataSyncRelationship { return $this->relationship; } + /** + * The changed record of a membership event under the channelId / userId names the Membership + * endpoints use. Null for every other type and on a delete. + */ + public function getMembership(): ?PNDataSyncMembership + { + return $this->membership; + } + public function getId(): ?string { return $this->id; @@ -200,13 +241,18 @@ public static function fromPayload( $version = PNDataSyncValue::stringOrNull($payload, 'version'); $event = PNDataSyncValue::stringOrNull($metadata, 'event'); $type = PNDataSyncValue::stringOrNull($metadata, 'type'); - $className = self::leafClassName(PNDataSyncValue::stringOrNull($metadata, 'className')); + $className = PNDataSyncValue::stringOrNull($metadata, 'className'); $classVersion = PNDataSyncValue::intOrNull($metadata, 'classVersion'); + $classLevel = PNDataSyncValue::stringOrNull($metadata, 'classLevel'); $data = PNDataSyncValue::arrayOrNull($payload, 'data') ?? []; $id = PNDataSyncValue::stringOrNull($data, 'id'); - if ($event === self::EVENT_DELETE) { + // The server is free to vary the casing of both fields, so neither is compared verbatim. + $eventName = $event === null ? null : strtolower($event); + $typeName = $type === null ? null : strtolower($type); + + if ($eventName === self::EVENT_DELETE) { return new self( $version, $event, @@ -214,6 +260,8 @@ public static function fromPayload( $type, $className, $classVersion, + $classLevel, + null, null, null, $id, @@ -226,18 +274,29 @@ public static function fromPayload( $entity = null; $relationship = null; + $membership = null; // The class name and version live in the metadata rather than in the record itself. - if ($type === self::TYPE_ENTITY) { + if (in_array($typeName, self::ENTITY_TYPES, true)) { $entity = PNDataSyncEntity::fromPayload(array_merge($data, [ 'entityClass' => $className, 'entityClassVersion' => $classVersion, ])); - } elseif ($type === self::TYPE_RELATIONSHIP) { - $relationship = PNDataSyncRelationship::fromPayload(array_merge($data, [ + } elseif (in_array($typeName, self::RELATIONSHIP_TYPES, true)) { + $record = array_merge($data, [ 'relationshipClass' => $className, 'relationshipClassVersion' => $classVersion, - ])); + ]); + + if ($typeName === self::TYPE_MEMBERSHIP) { + $membership = PNDataSyncMembership::fromPayload($record); + + // A membership names its two sides channelId and userId on the wire. + $record['entityAId'] = $membership->getChannelId(); + $record['entityBId'] = $membership->getUserId(); + } + + $relationship = PNDataSyncRelationship::fromPayload($record); } return new self( @@ -247,8 +306,10 @@ public static function fromPayload( $type, $className, $classVersion, + $classLevel, $entity, $relationship, + $membership, $id, null, $channel, @@ -256,19 +317,4 @@ public static function fromPayload( $timetoken ); } - - /** - * Class names arrive colon-delimited with their inherited classes ("Base:vehicle"); only the - * most derived one identifies the record. - */ - private static function leafClassName(?string $className): ?string - { - if ($className === null || $className === '') { - return $className; - } - - $segments = explode(':', $className); - - return end($segments); - } } diff --git a/tests/helpers/DataSyncEventCollector.php b/tests/helpers/DataSyncEventCollector.php new file mode 100644 index 00000000..1e596aea --- /dev/null +++ b/tests/helpers/DataSyncEventCollector.php @@ -0,0 +1,145 @@ +channel = $channel; + $this->expected = $expected; + $this->trigger = $trigger; + $this->accepts = $accepts; + $this->roundsLeft = $graceRounds; + } + + /** + * @return PNDataSyncEventResult[] + */ + public function getEvents(): array + { + return $this->events; + } + + /** + * @param PubNub $pubnub + * @param PNStatus $status + * @return void + */ + public function status($pubnub, $status): void + { + if ($status->getCategory() !== PNStatusCategory::PNConnectedCategory) { + return; + } + + ($this->trigger)(); + $this->pump($pubnub); + } + + /** + * @param PubNub $pubnub + * @param PNMessageResult $message + * @return void + */ + public function message($pubnub, $message): void + { + $payload = $message->getMessage(); + + if (is_array($payload) && ($payload['sentinel'] ?? null) === self::SENTINEL) { + $this->pump($pubnub); + } + } + + /** + * @param PubNub $pubnub + * @param mixed $presence + * @return void + */ + public function presence($pubnub, $presence): void + { + } + + /** + * @param PubNub $pubnub + * @param PNDataSyncEventResult $event + * @return void + */ + public function dataSyncEvent($pubnub, $event): void + { + if ($this->accepts !== null && !($this->accepts)($event)) { + return; + } + + $this->events[] = $event; + + if (count($this->events) >= $this->expected) { + throw new PubNubUnsubscribeException(); + } + } + + /** + * @param PubNub $pubnub + * @throws PubNubUnsubscribeException once the events are in or the grace period is spent. + */ + private function pump($pubnub): void + { + if (count($this->events) >= $this->expected || $this->roundsLeft <= 0) { + throw new PubNubUnsubscribeException(); + } + + $this->roundsLeft--; + sleep(1); + + $pubnub->publish() + ->channel($this->channel) + ->message(['sentinel' => self::SENTINEL]) + ->sync(); + } +} diff --git a/tests/integrational/dataSync/DataSyncEventTest.php b/tests/integrational/dataSync/DataSyncEventTest.php new file mode 100644 index 00000000..700b1062 --- /dev/null +++ b/tests/integrational/dataSync/DataSyncEventTest.php @@ -0,0 +1,366 @@ +entityClass = getenv('DATASYNC_ENTITY_CLASS') ?: ''; + $this->relationshipClass = getenv('DATASYNC_ENTITY_RELATIONSHIP') ?: ''; + + if ($subscribeKey === '' || $publishKey === '' || $this->entityClass === '') { + $this->markTestSkipped( + 'Set DATASYNC_SUBSCRIBE_KEY, DATASYNC_PUBLISH_KEY and DATASYNC_ENTITY_CLASS to run the ' + . 'DataSync event tests against a live keyset.' + ); + } + + $config = new PNConfiguration(); + $config->setSubscribeKey($subscribeKey); + $config->setPublishKey($publishKey); + $config->setUuid('datasync-event-test-' . uniqid()); + + $secretKey = getenv('DATASYNC_SECRET_KEY') ?: ''; + + if ($secretKey !== '') { + $config->setSecretKey($secretKey); + } + + if ($origin = getenv('DATASYNC_ORIGIN')) { + $config->setOrigin($origin); + } + + $this->pubnub = new PubNub($config); + } + + /** + * Subscribes to $channel, runs $trigger once connected and returns the events it produced. + * + * @param callable $trigger + * @param callable|null $accepts + * @return PNDataSyncEventResult[] + */ + private function captureEvents( + string $channel, + int $expected, + callable $trigger, + ?callable $accepts = null + ): array { + $collector = new DataSyncEventCollector($channel, $expected, $trigger, $accepts); + $this->pubnub->addListener($collector); + + try { + $this->pubnub->subscribe()->channels([$channel])->execute(); + } finally { + $this->pubnub->removeListener($collector); + } + + $events = $collector->getEvents(); + + $this->assertCount( + $expected, + $events, + sprintf('expected %d DataSync events on channel "%s", got %d', $expected, $channel, count($events)) + ); + + return $events; + } + + /** + * Accepts the events of one record type, ignoring anything else sharing the channel. + */ + private function ofType(string $type): callable + { + return static fn(PNDataSyncEventResult $event): bool + => strtolower((string) $event->getType()) === $type; + } + + /** + * @param PNDataSyncEventResult[] $events + */ + private function eventNamed(array $events, string $name): PNDataSyncEventResult + { + foreach ($events as $event) { + if (strtolower((string) $event->getEvent()) === $name) { + return $event; + } + } + + throw new \RuntimeException(sprintf('no "%s" event among the %d received', $name, count($events))); + } + + public function testGenericEntityEvents(): void + { + $entityId = 'php-sdk-event-' . uniqid(); + + $events = $this->captureEvents($entityId, 3, function () use ($entityId) { + $this->pubnub->dataSync()->createEntity() + ->entityId($entityId) + ->entityClass($this->entityClass) + ->entityClassVersion(1) + ->status('active') + ->payload(['make' => 'Toyota']) + ->sync(); + + $this->pubnub->dataSync()->updateEntity() + ->entityId($entityId) + ->patch((new PNDataSyncPatch())->replace('/status', 'patched')) + ->sync(); + + $this->pubnub->dataSync()->deleteEntity()->entityId($entityId)->sync(); + }); + + $created = $this->eventNamed($events, 'create'); + + $this->assertSame(PNDataSyncEventResult::SOURCE, $created->getSource()); + $this->assertSame('entity', strtolower((string) $created->getType())); + $this->assertSame($entityId, $created->getChannel()); + $this->assertNotEmpty($created->getTimetoken()); + // The class name arrives bare, with the level telling built-in and custom classes apart. + $this->assertSame($this->entityClass, $created->getClassName()); + $this->assertSame(1, $created->getClassVersion()); + $this->assertNotEmpty($created->getClassLevel()); + + $entity = $created->getEntity(); + $this->assertNotNull($entity); + $this->assertSame($entityId, $entity->getId()); + $this->assertSame('active', $entity->getStatus()); + $this->assertSame(['make' => 'Toyota'], $entity->getPayload()); + $this->assertSame($this->entityClass, $entity->getEntityClass()); + $this->assertNull($created->getRelationship()); + + $updated = $this->eventNamed($events, 'update'); + $this->assertNotNull($updated->getEntity()); + $this->assertSame('patched', $updated->getEntity()->getStatus()); + + $deleted = $this->eventNamed($events, 'delete'); + $this->assertSame($entityId, $deleted->getId()); + $this->assertNotEmpty($deleted->getDeletedAt()); + $this->assertNull($deleted->getEntity()); + } + + public function testGenericRelationshipEvents(): void + { + if ($this->relationshipClass === '') { + $this->markTestSkipped('Set DATASYNC_ENTITY_RELATIONSHIP to run the relationship event test.'); + } + + $entityAId = $this->createEntity(); + $entityBId = $this->createEntity(); + $relationshipId = 'php-sdk-event-rel-' . uniqid(); + + try { + // A relationship event lands on the channels of the two records it links, not its own id. + $trigger = function () use ($entityAId, $entityBId, $relationshipId): void { + $this->pubnub->dataSync()->createRelationship() + ->relationshipId($relationshipId) + ->entityAId($entityAId) + ->entityBId($entityBId) + ->relationshipClass($this->relationshipClass) + ->relationshipClassVersion(1) + ->status('active') + ->payload(['role' => 'owner']) + ->sync(); + + $this->pubnub->dataSync()->deleteRelationship() + ->relationshipId($relationshipId) + ->sync(); + }; + + $events = $this->captureEvents($entityAId, 2, $trigger, $this->ofType('relationship')); + + $created = $this->eventNamed($events, 'create'); + + $this->assertSame('relationship', strtolower((string) $created->getType())); + $this->assertSame($entityAId, $created->getChannel()); + $this->assertSame($this->relationshipClass, $created->getClassName()); + $this->assertNull($created->getEntity()); + $this->assertNull($created->getMembership()); + + $relationship = $created->getRelationship(); + $this->assertNotNull($relationship); + $this->assertSame($relationshipId, $relationship->getId()); + $this->assertSame($entityAId, $relationship->getEntityAId()); + $this->assertSame($entityBId, $relationship->getEntityBId()); + $this->assertSame('active', $relationship->getStatus()); + $this->assertSame(['role' => 'owner'], $relationship->getPayload()); + + $deleted = $this->eventNamed($events, 'delete'); + $this->assertSame($relationshipId, $deleted->getId()); + $this->assertNotEmpty($deleted->getDeletedAt()); + $this->assertNull($deleted->getRelationship()); + } finally { + $this->pubnub->dataSync()->deleteEntity()->entityId($entityAId)->sync(); + $this->pubnub->dataSync()->deleteEntity()->entityId($entityBId)->sync(); + } + } + + /** + * User is a predefined entity class, so its events are entity-shaped and only the type differs. + */ + public function testPredefinedUserEvents(): void + { + $userId = 'php-sdk-event-user-' . uniqid(); + + $events = $this->captureEvents($userId, 2, function () use ($userId) { + $this->pubnub->dataSync()->createUser() + ->userId($userId) + ->entityClassVersion(1) + ->status('active') + ->payload(['name' => 'Alice']) + ->sync(); + + $this->pubnub->dataSync()->deleteUser()->userId($userId)->sync(); + }); + + $created = $this->eventNamed($events, 'create'); + + $this->assertSame('user', strtolower((string) $created->getType())); + $this->assertSame($userId, $created->getChannel()); + $this->assertNotEmpty($created->getClassName()); + $this->assertNotEmpty($created->getClassLevel()); + $this->assertNull($created->getRelationship()); + + $user = $created->getEntity(); + $this->assertNotNull($user); + $this->assertSame($userId, $user->getId()); + $this->assertSame('active', $user->getStatus()); + $this->assertSame(['name' => 'Alice'], $user->getPayload()); + + $deleted = $this->eventNamed($events, 'delete'); + $this->assertSame($userId, $deleted->getId()); + $this->assertNotEmpty($deleted->getDeletedAt()); + } + + /** + * Membership is a predefined relationship class between a Channel and a User, and its events + * travel on the channels of both records it links. + */ + public function testPredefinedMembershipEvents(): void + { + $channelId = $this->createChannel(); + $userId = $this->createUser(); + $membershipId = 'php-sdk-event-mem-' . uniqid(); + + try { + $trigger = function () use ($channelId, $userId, $membershipId): void { + $this->pubnub->dataSync()->createMembership() + ->membershipId($membershipId) + ->channelId($channelId) + ->userId($userId) + ->relationshipClassVersion(1) + ->status('active') + ->payload(['role' => 'member']) + ->sync(); + + $this->pubnub->dataSync()->deleteMembership() + ->membershipId($membershipId) + ->sync(); + }; + + $events = $this->captureEvents($userId, 2, $trigger, $this->ofType('membership')); + + $created = $this->eventNamed($events, 'create'); + + $this->assertSame('membership', strtolower((string) $created->getType())); + $this->assertSame($userId, $created->getChannel()); + $this->assertNotEmpty($created->getClassLevel()); + $this->assertNull($created->getEntity()); + + $membership = $created->getMembership(); + $this->assertNotNull($membership); + $this->assertSame($membershipId, $membership->getId()); + $this->assertSame($channelId, $membership->getChannelId()); + $this->assertSame($userId, $membership->getUserId()); + $this->assertSame('active', $membership->getStatus()); + $this->assertSame(['role' => 'member'], $membership->getPayload()); + + // The same record is also reported as a relationship, channel first and user second. + $relationship = $created->getRelationship(); + $this->assertNotNull($relationship); + $this->assertSame($channelId, $relationship->getEntityAId()); + $this->assertSame($userId, $relationship->getEntityBId()); + + $deleted = $this->eventNamed($events, 'delete'); + $this->assertSame($membershipId, $deleted->getId()); + $this->assertNotEmpty($deleted->getDeletedAt()); + $this->assertNull($deleted->getMembership()); + } finally { + $this->pubnub->dataSync()->deleteUser()->userId($userId)->sync(); + $this->pubnub->dataSync()->deleteChannel()->channelId($channelId)->sync(); + } + } + + private function createEntity(): string + { + $entityId = 'php-sdk-event-' . uniqid(); + + $this->pubnub->dataSync()->createEntity() + ->entityId($entityId) + ->entityClass($this->entityClass) + ->entityClassVersion(1) + ->status('active') + ->payload(['name' => 'entity-' . $entityId]) + ->sync(); + + return $entityId; + } + + private function createUser(): string + { + $userId = 'php-sdk-event-user-' . uniqid(); + + $this->pubnub->dataSync()->createUser() + ->userId($userId) + ->entityClassVersion(1) + ->status('active') + ->payload(['name' => 'user-' . $userId]) + ->sync(); + + return $userId; + } + + private function createChannel(): string + { + $channelId = 'php-sdk-event-channel-' . uniqid(); + + $this->pubnub->dataSync()->createChannel() + ->channelId($channelId) + ->entityClassVersion(1) + ->status('active') + ->payload(['name' => 'channel-' . $channelId]) + ->sync(); + + return $channelId; + } +} diff --git a/tests/integrational/dataSync/DataSyncSubscribeTest.php b/tests/integrational/dataSync/DataSyncSubscribeTest.php new file mode 100644 index 00000000..b5ea154e --- /dev/null +++ b/tests/integrational/dataSync/DataSyncSubscribeTest.php @@ -0,0 +1,203 @@ +client = new PsrStubClient(); + $this->pubnub_demo->setClient($this->client); + $this->pubnub_demo->getConfiguration()->setUuid(self::UUID); + } + + /** + * Answers the handshake, then hands out one message and lets the loop end on the third + * request, for which no stub exists. + */ + private function stubSubscribe(string $message): void + { + $this->client->addStub((new PsrStub('/v2/subscribe/demo/test/0')) + ->withQuery([ + 'pnsdk' => $this->encodedSdkName, + 'uuid' => self::UUID, + ]) + ->setResponseBody(self::HANDSHAKE)); + + $this->client->addStub((new PsrStub('/v2/subscribe/demo/test/0')) + ->withQuery([ + 'tt' => '14818963579052943', + 'tr' => '12', + 'pnsdk' => $this->encodedSdkName, + 'uuid' => self::UUID, + ]) + ->setResponseBody('{"t":{"t":"14921661962885137","r":12},"m":[' . $message . ']}')); + } + + /** + * @param array $payload + */ + private function envelope(array $payload, int $messageType = 5): string + { + return (string) json_encode([ + 'a' => '1', + 'f' => 0, + 'e' => $messageType, + 'i' => 'publisher-uuid', + 'p' => ['t' => '14921661962867845', 'r' => 12], + 'k' => 'demo', + 'c' => 'test', + 'u' => [], + 'd' => $payload, + 'b' => 'test', + ]); + } + + public function testDataSyncEventReachesTheListener(): void + { + $this->stubSubscribe($this->envelope([ + 'version' => '3.0', + 'metadata' => [ + 'event' => 'create', + 'source' => 'data-sync', + 'type' => 'entity', + 'className' => 'vehicle', + 'classVersion' => '1', + 'classLevel' => 'SubKey', + ], + 'data' => [ + 'id' => 'vehicle-1', + 'status' => 'active', + 'payload' => ['make' => 'Toyota'], + ], + ])); + + $callback = new DataSyncSubscribeCallback(); + $this->pubnub_demo->addListener($callback); + $this->pubnub_demo->subscribe()->channel('test')->execute(); + + $this->assertCount(1, $callback->dataSyncEvents); + $this->assertCount(0, $callback->messages); + + $event = $callback->dataSyncEvents[0]; + $this->assertSame('create', $event->getEvent()); + $this->assertSame('entity', $event->getType()); + $this->assertSame('SubKey', $event->getClassLevel()); + $this->assertSame('test', $event->getChannel()); + $this->assertSame('14921661962867845', $event->getTimetoken()); + $this->assertNotNull($event->getEntity()); + $this->assertSame('vehicle-1', $event->getEntity()->getId()); + $this->assertSame(['make' => 'Toyota'], $event->getEntity()->getPayload()); + } + + public function testMembershipEventReachesTheListener(): void + { + $this->stubSubscribe($this->envelope([ + 'metadata' => [ + 'event' => 'update', + 'source' => 'data-sync', + 'type' => 'membership', + 'className' => 'Membership', + 'classVersion' => '1', + 'classLevel' => 'Global', + ], + 'data' => [ + 'id' => 'mem-1', + 'channelId' => 'channel-1', + 'userId' => 'user-1', + ], + ])); + + $callback = new DataSyncSubscribeCallback(); + $this->pubnub_demo->addListener($callback); + $this->pubnub_demo->subscribe()->channel('test')->execute(); + + $this->assertCount(1, $callback->dataSyncEvents); + + $membership = $callback->dataSyncEvents[0]->getMembership(); + $this->assertNotNull($membership); + $this->assertSame('channel-1', $membership->getChannelId()); + $this->assertSame('user-1', $membership->getUserId()); + } + + public function testUnrelatedMessageTypeFiveFallsThroughToMessage(): void + { + $this->stubSubscribe($this->envelope(['metadata' => ['source' => 'something-else']])); + + $callback = new DataSyncSubscribeCallback(); + $this->pubnub_demo->addListener($callback); + $this->pubnub_demo->subscribe()->channel('test')->execute(); + + $this->assertCount(0, $callback->dataSyncEvents); + $this->assertCount(1, $callback->messages); + } + + public function testRegularMessageIsUnaffected(): void + { + $this->stubSubscribe($this->envelope(['text' => 'hey'], 0)); + + $callback = new DataSyncSubscribeCallback(); + $this->pubnub_demo->addListener($callback); + $this->pubnub_demo->subscribe()->channel('test')->execute(); + + $this->assertCount(0, $callback->dataSyncEvents); + $this->assertCount(1, $callback->messages); + $this->assertSame(['text' => 'hey'], $callback->messages[0]); + } +} + +//phpcs:ignore PSR1.Classes.ClassDeclaration +class DataSyncSubscribeCallback extends SubscribeCallback +{ + /** @var PNDataSyncEventResult[] */ + public array $dataSyncEvents = []; + + /** @var mixed[] */ + public array $messages = []; + + public function status($pubnub, $status): void + { + } + + /** + * @param \PubNub\PubNub $pubnub + * @param \PubNub\Models\Consumer\PubSub\PNMessageResult $message + */ + public function message($pubnub, $message): void + { + $this->messages[] = $message->getMessage(); + } + + /** + * @param \PubNub\PubNub $pubnub + * @param mixed $presence + */ + public function presence($pubnub, $presence): void + { + } + + public function dataSyncEvent($pubnub, $event): void + { + $this->dataSyncEvents[] = $event; + } +} diff --git a/tests/unit/dataSync/DataSyncEventResultTest.php b/tests/unit/dataSync/DataSyncEventResultTest.php index c3e0ae08..17d682e6 100644 --- a/tests/unit/dataSync/DataSyncEventResultTest.php +++ b/tests/unit/dataSync/DataSyncEventResultTest.php @@ -22,8 +22,9 @@ private function payload(array $overrides = []): array 'event' => 'create', 'source' => 'data-sync', 'type' => 'entity', - 'className' => 'Base:vehicle', + 'className' => 'vehicle', 'classVersion' => '1', + 'classLevel' => 'SubKey', ], 'data' => [ 'id' => 'vehicle-1', @@ -55,13 +56,46 @@ public function testParsesEntityCreateEvent(): void $this->assertSame('abc123', $entity->getETag()); } - public function testKeepsOnlyTheMostDerivedClassName(): void + public function testSurfacesTheClassNameUnchanged(): void + { + $event = PNDataSyncEventResult::fromPayload($this->payload([ + 'metadata' => [ + 'event' => 'create', + 'source' => 'data-sync', + 'type' => 'entity', + 'className' => 'fleet:vehicle', + 'classVersion' => '1', + ], + ])); + + $this->assertNotNull($event); + $this->assertSame('fleet:vehicle', $event->getClassName()); + $this->assertSame('fleet:vehicle', $event->getEntity()->getEntityClass()); + } + + public function testReadsTheClassLevel(): void { $event = PNDataSyncEventResult::fromPayload($this->payload()); $this->assertNotNull($event); - $this->assertSame('vehicle', $event->getClassName()); - $this->assertSame('vehicle', $event->getEntity()->getEntityClass()); + $this->assertSame('SubKey', $event->getClassLevel()); + } + + public function testMatchesEventAndTypeRegardlessOfCasing(): void + { + $event = PNDataSyncEventResult::fromPayload($this->payload([ + 'metadata' => [ + 'event' => 'CREATE', + 'source' => 'data-sync', + 'type' => 'Entity', + 'className' => 'vehicle', + 'classVersion' => '1', + ], + ])); + + $this->assertNotNull($event); + $this->assertSame('CREATE', $event->getEvent(), 'the raw casing is preserved'); + $this->assertNotNull($event->getEntity()); } public function testCastsClassVersionToInt(): void @@ -123,6 +157,134 @@ public function testParsesRelationshipEvent(): void $this->assertSame('vehicle-1', $relationship->getEntityBId()); $this->assertSame('owns', $relationship->getRelationshipClass()); $this->assertSame(2, $relationship->getRelationshipClassVersion()); + $this->assertNull($event->getMembership()); + } + + /** + * @return array + */ + public static function predefinedEntityTypeProvider(): array + { + return ['user' => ['user'], 'channel' => ['channel']]; + } + + /** + * The predefined User and Channel classes are entities, so they arrive shaped like one. + * + * @dataProvider predefinedEntityTypeProvider + */ + public function testParsesPredefinedEntityEvent(string $type): void + { + $event = PNDataSyncEventResult::fromPayload($this->payload([ + 'metadata' => [ + 'event' => 'create', + 'source' => 'data-sync', + 'type' => $type, + 'className' => ucfirst($type), + 'classVersion' => '1', + 'classLevel' => 'Global', + ], + 'data' => [ + 'id' => $type . '-1', + 'status' => 'active', + 'payload' => ['name' => 'Alice'], + ], + ])); + + $this->assertNotNull($event); + $this->assertSame($type, $event->getType()); + $this->assertSame('Global', $event->getClassLevel()); + $this->assertNull($event->getRelationship()); + + $entity = $event->getEntity(); + $this->assertNotNull($entity); + $this->assertSame($type . '-1', $entity->getId()); + $this->assertSame(ucfirst($type), $entity->getEntityClass()); + $this->assertSame(['name' => 'Alice'], $entity->getPayload()); + } + + public function testParsesMembershipEventAsBothARelationshipAndAMembership(): void + { + $event = PNDataSyncEventResult::fromPayload($this->payload([ + 'metadata' => [ + 'event' => 'update', + 'source' => 'data-sync', + 'type' => 'membership', + 'className' => 'Membership', + 'classVersion' => '1', + 'classLevel' => 'Global', + ], + 'data' => [ + 'id' => 'mem-1', + 'channelId' => 'channel-1', + 'userId' => 'user-1', + 'status' => 'active', + 'payload' => ['role' => 'member'], + ], + ])); + + $this->assertNotNull($event); + $this->assertSame('membership', $event->getType()); + $this->assertNull($event->getEntity()); + + $membership = $event->getMembership(); + $this->assertNotNull($membership); + $this->assertSame('mem-1', $membership->getId()); + $this->assertSame('channel-1', $membership->getChannelId()); + $this->assertSame('user-1', $membership->getUserId()); + $this->assertSame('Membership', $membership->getRelationshipClass()); + $this->assertSame(1, $membership->getRelationshipClassVersion()); + $this->assertSame(['role' => 'member'], $membership->getPayload()); + + // The same record also arrives as a relationship, with the channel first and the user second. + $relationship = $event->getRelationship(); + $this->assertNotNull($relationship); + $this->assertSame('mem-1', $relationship->getId()); + $this->assertSame('channel-1', $relationship->getEntityAId()); + $this->assertSame('user-1', $relationship->getEntityBId()); + } + + public function testDeleteEventOfAPredefinedTypeCarriesNoRecord(): void + { + $event = PNDataSyncEventResult::fromPayload($this->payload([ + 'metadata' => [ + 'event' => 'delete', + 'source' => 'data-sync', + 'type' => 'membership', + 'className' => 'Membership', + 'classVersion' => '1', + ], + 'data' => [ + 'id' => 'mem-1', + 'deletedAt' => '2026-08-21T11:00:00Z', + ], + ])); + + $this->assertNotNull($event); + $this->assertSame('mem-1', $event->getId()); + $this->assertSame('2026-08-21T11:00:00Z', $event->getDeletedAt()); + $this->assertNull($event->getMembership()); + $this->assertNull($event->getRelationship()); + $this->assertNull($event->getEntity()); + } + + public function testUnknownTypeCarriesNoRecord(): void + { + $event = PNDataSyncEventResult::fromPayload($this->payload([ + 'metadata' => [ + 'event' => 'create', + 'source' => 'data-sync', + 'type' => 'something-new', + 'className' => 'vehicle', + 'classVersion' => '1', + ], + ])); + + $this->assertNotNull($event); + $this->assertSame('something-new', $event->getType()); + $this->assertNull($event->getEntity()); + $this->assertNull($event->getRelationship()); + $this->assertNull($event->getMembership()); } public function testIgnoresPayloadsFromOtherSources(): void From 144e6912a0343b0b1e177ea883177e824d65fdbe Mon Sep 17 00:00:00 2001 From: "PUBNUB\\jakub.grzesiowski" Date: Mon, 21 Sep 2026 14:21:49 +0200 Subject: [PATCH 3/8] User, channels, and memberships --- src/PubNub/Endpoints/Access/GrantToken.php | 16 +- .../DataSync/Channel/GetChannels.php | 5 +- .../DataSync/DataSyncCollectionEndpoint.php | 13 +- .../Endpoints/DataSync/DataSyncEndpoint.php | 27 ++ .../Endpoints/DataSync/Entity/GetEntities.php | 5 +- .../DataSync/Membership/GetMemberships.php | 5 +- .../Relationship/GetRelationships.php | 7 +- .../Endpoints/DataSync/User/GetUsers.php | 5 +- .../PNDataSyncProjectionScope.php | 85 ++++- tests/bootstrap.php | 1 + .../dataSync/DataSyncGrantTokenTest.php | 29 ++ .../dataSync/DataSyncRequestTest.php | 38 ++ .../dataSync/DataSyncGrantTest.php | 352 ++++++++++++++++++ .../DataSyncPredefinedLifecycleTest.php | 340 +++++++++++++++++ .../dataSync/DataSyncProjectionTest.php | 264 +++++++++++++ .../unit/dataSync/DataSyncTokenParseTest.php | 20 + 16 files changed, 1169 insertions(+), 43 deletions(-) create mode 100644 tests/integrational/dataSync/DataSyncGrantTest.php create mode 100644 tests/integrational/dataSync/DataSyncPredefinedLifecycleTest.php create mode 100644 tests/integrational/dataSync/DataSyncProjectionTest.php diff --git a/src/PubNub/Endpoints/Access/GrantToken.php b/src/PubNub/Endpoints/Access/GrantToken.php index 5e3519aa..0b133b4d 100644 --- a/src/PubNub/Endpoints/Access/GrantToken.php +++ b/src/PubNub/Endpoints/Access/GrantToken.php @@ -15,6 +15,13 @@ class GrantToken extends Endpoint { protected const PATH = '/v3/pam/%s/grant'; + /** + * Record families a projection can be assigned to. Wider than the three families that take + * DataSync permissions, because users and channels draw their permissions from the uuid and + * channel scopes while still needing a projection of their own. + */ + private const PROJECTION_TYPES = ['entities', 'users', 'channels', 'relationships', 'memberships']; + /** @var int */ protected $ttl; @@ -246,8 +253,11 @@ public function addDataSyncMembershipPatterns($res) * Restricts which fields of a DataSync record the token holder can see. * * Expects up to two scopes, "resources" for exact identifiers and "patterns" for regular - * expressions, each holding any of "entities", "relationships" and "memberships" mapped from - * identifier to projection name. Use "__default__" for the base projection: + * expressions, each holding any of "entities", "users", "channels", "relationships" and + * "memberships" mapped from identifier to projection name. These buckets only assign + * projections: the permissions that go with them come from the DataSync entity scope for + * entities, and from the shared uuid and channel scopes for users and channels. + * Use "__default__" for the base projection: * * ->dataSyncProjections([ * 'resources' => ['entities' => ['vehicle-1' => '__default__']], @@ -280,7 +290,7 @@ private function buildProjectionsMeta() $flat = []; - foreach (['entities', 'relationships', 'memberships'] as $type) { + foreach (self::PROJECTION_TYPES as $type) { $map = $this->dataSyncProjections[$scopeName][$type] ?? null; if (!is_array($map)) { diff --git a/src/PubNub/Endpoints/DataSync/Channel/GetChannels.php b/src/PubNub/Endpoints/DataSync/Channel/GetChannels.php index 623d83dd..f59c046d 100644 --- a/src/PubNub/Endpoints/DataSync/Channel/GetChannels.php +++ b/src/PubNub/Endpoints/DataSync/Channel/GetChannels.php @@ -6,7 +6,6 @@ use PubNub\Enums\PNHttpMethod; use PubNub\Enums\PNOperationType; use PubNub\Models\Consumer\DataSync\PNDataSyncChannelsResult; -use PubNub\PubNubUtil; /** * Lists DataSync channels, one page at a time. @@ -75,7 +74,7 @@ protected function customParams() $params = array_merge($this->defaultParams(), $this->collectionParams()); if (!empty($this->entityClass)) { - $params['entity_class'] = PubNubUtil::urlEncode($this->entityClass); + $params['entity_class'] = $this->entityClass; } if ($this->entityClassVersion !== null) { @@ -83,7 +82,7 @@ protected function customParams() } if (!empty($this->entityClassLevel)) { - $params['entity_class_level'] = PubNubUtil::urlEncode($this->entityClassLevel); + $params['entity_class_level'] = $this->entityClassLevel; } return $params; diff --git a/src/PubNub/Endpoints/DataSync/DataSyncCollectionEndpoint.php b/src/PubNub/Endpoints/DataSync/DataSyncCollectionEndpoint.php index 69f2a26c..99a876b5 100644 --- a/src/PubNub/Endpoints/DataSync/DataSyncCollectionEndpoint.php +++ b/src/PubNub/Endpoints/DataSync/DataSyncCollectionEndpoint.php @@ -2,8 +2,6 @@ namespace PubNub\Endpoints\DataSync; -use PubNub\PubNubUtil; - /** * Base for the paginated DataSync list endpoints. * @@ -89,7 +87,8 @@ public function sort($sort): static } /** - * Query parameters shared by every list endpoint, already URL-encoded. + * Query parameters shared by every list endpoint, raw - DataSyncEndpoint::buildParams() + * encodes them once the signature has been taken. * * @return array */ @@ -98,7 +97,7 @@ protected function collectionParams(): array $params = []; if (!empty($this->cursor)) { - $params['cursor'] = PubNubUtil::urlEncode($this->cursor); + $params['cursor'] = $this->cursor; } if ($this->limit !== null) { @@ -106,17 +105,17 @@ protected function collectionParams(): array } if (!empty($this->filterFast)) { - $params['filter_fast'] = PubNubUtil::urlEncode($this->filterFast); + $params['filter_fast'] = $this->filterFast; } if (!empty($this->filter)) { - $params['filter'] = PubNubUtil::urlEncode($this->filter); + $params['filter'] = $this->filter; } $sort = $this->buildSortValue(); if ($sort !== null) { - $params['sort'] = PubNubUtil::urlEncode($sort); + $params['sort'] = $sort; } return $params; diff --git a/src/PubNub/Endpoints/DataSync/DataSyncEndpoint.php b/src/PubNub/Endpoints/DataSync/DataSyncEndpoint.php index c7ba19b5..225686f6 100644 --- a/src/PubNub/Endpoints/DataSync/DataSyncEndpoint.php +++ b/src/PubNub/Endpoints/DataSync/DataSyncEndpoint.php @@ -73,6 +73,33 @@ protected function customParams() return $this->defaultParams(); } + /** + * URL-encodes the DataSync query parameters, after the PAM signature has been taken. + * + * The signature is computed over an encoding of the parameters the endpoint hands over, so a + * value that arrives here already encoded would be signed doubly encoded while the wire only + * ever carries it encoded once, and the request would be rejected. Subclasses therefore leave + * their values raw, and this puts them into their final form at the same point the shared + * endpoint does it for uuid, auth and channel. + * + * @return array + */ + protected function buildParams() + { + $params = parent::buildParams(); + + // Whatever the endpoint added on top of the parameters every request carries. + $ownKeys = array_diff(array_keys($this->customParams()), array_keys($this->defaultParams())); + + foreach ($ownKeys as $key) { + if (isset($params[$key])) { + $params[$key] = PubNubUtil::urlEncode($params[$key]); + } + } + + return $params; + } + /** * @return array */ diff --git a/src/PubNub/Endpoints/DataSync/Entity/GetEntities.php b/src/PubNub/Endpoints/DataSync/Entity/GetEntities.php index 7e4f758b..8f2c9ac2 100644 --- a/src/PubNub/Endpoints/DataSync/Entity/GetEntities.php +++ b/src/PubNub/Endpoints/DataSync/Entity/GetEntities.php @@ -7,7 +7,6 @@ use PubNub\Enums\PNOperationType; use PubNub\Exceptions\PubNubValidationException; use PubNub\Models\Consumer\DataSync\PNDataSyncEntitiesResult; -use PubNub\PubNubUtil; /** * Lists the DataSync entities of one class, one page at a time. @@ -77,14 +76,14 @@ protected function customParams() { $params = array_merge($this->defaultParams(), $this->collectionParams()); - $params['entity_class'] = PubNubUtil::urlEncode((string) $this->entityClass); + $params['entity_class'] = (string) $this->entityClass; if ($this->entityClassVersion !== null) { $params['entity_class_version'] = (string) $this->entityClassVersion; } if (!empty($this->entityClassLevel)) { - $params['entity_class_level'] = PubNubUtil::urlEncode($this->entityClassLevel); + $params['entity_class_level'] = $this->entityClassLevel; } return $params; diff --git a/src/PubNub/Endpoints/DataSync/Membership/GetMemberships.php b/src/PubNub/Endpoints/DataSync/Membership/GetMemberships.php index 837034cb..054f3000 100644 --- a/src/PubNub/Endpoints/DataSync/Membership/GetMemberships.php +++ b/src/PubNub/Endpoints/DataSync/Membership/GetMemberships.php @@ -6,7 +6,6 @@ use PubNub\Enums\PNHttpMethod; use PubNub\Enums\PNOperationType; use PubNub\Models\Consumer\DataSync\PNDataSyncMembershipsResult; -use PubNub\PubNubUtil; /** * Lists DataSync memberships, one page at a time. @@ -70,11 +69,11 @@ protected function customParams() $params = array_merge($this->defaultParams(), $this->collectionParams()); if (!empty($this->channelId)) { - $params['channel_id'] = PubNubUtil::urlEncode($this->channelId); + $params['channel_id'] = $this->channelId; } if (!empty($this->userId)) { - $params['user_id'] = PubNubUtil::urlEncode($this->userId); + $params['user_id'] = $this->userId; } if ($this->relationshipClassVersion !== null) { diff --git a/src/PubNub/Endpoints/DataSync/Relationship/GetRelationships.php b/src/PubNub/Endpoints/DataSync/Relationship/GetRelationships.php index cbc0fea0..6f2c2776 100644 --- a/src/PubNub/Endpoints/DataSync/Relationship/GetRelationships.php +++ b/src/PubNub/Endpoints/DataSync/Relationship/GetRelationships.php @@ -7,7 +7,6 @@ use PubNub\Enums\PNOperationType; use PubNub\Exceptions\PubNubValidationException; use PubNub\Models\Consumer\DataSync\PNDataSyncRelationshipsResult; -use PubNub\PubNubUtil; /** * Lists the DataSync relationships of one class, one page at a time. @@ -88,18 +87,18 @@ protected function customParams() { $params = array_merge($this->defaultParams(), $this->collectionParams()); - $params['relationship_class'] = PubNubUtil::urlEncode((string) $this->relationshipClass); + $params['relationship_class'] = (string) $this->relationshipClass; if ($this->relationshipClassVersion !== null) { $params['relationship_class_version'] = (string) $this->relationshipClassVersion; } if (!empty($this->entityAId)) { - $params['entity_a_id'] = PubNubUtil::urlEncode($this->entityAId); + $params['entity_a_id'] = $this->entityAId; } if (!empty($this->entityBId)) { - $params['entity_b_id'] = PubNubUtil::urlEncode($this->entityBId); + $params['entity_b_id'] = $this->entityBId; } return $params; diff --git a/src/PubNub/Endpoints/DataSync/User/GetUsers.php b/src/PubNub/Endpoints/DataSync/User/GetUsers.php index b401f020..a9237bae 100644 --- a/src/PubNub/Endpoints/DataSync/User/GetUsers.php +++ b/src/PubNub/Endpoints/DataSync/User/GetUsers.php @@ -6,7 +6,6 @@ use PubNub\Enums\PNHttpMethod; use PubNub\Enums\PNOperationType; use PubNub\Models\Consumer\DataSync\PNDataSyncUsersResult; -use PubNub\PubNubUtil; /** * Lists DataSync users, one page at a time. @@ -75,7 +74,7 @@ protected function customParams() $params = array_merge($this->defaultParams(), $this->collectionParams()); if (!empty($this->entityClass)) { - $params['entity_class'] = PubNubUtil::urlEncode($this->entityClass); + $params['entity_class'] = $this->entityClass; } if ($this->entityClassVersion !== null) { @@ -83,7 +82,7 @@ protected function customParams() } if (!empty($this->entityClassLevel)) { - $params['entity_class_level'] = PubNubUtil::urlEncode($this->entityClassLevel); + $params['entity_class_level'] = $this->entityClassLevel; } return $params; diff --git a/src/PubNub/Models/Consumer/AccessManager/PNDataSyncProjectionScope.php b/src/PubNub/Models/Consumer/AccessManager/PNDataSyncProjectionScope.php index 79837fb6..83961931 100644 --- a/src/PubNub/Models/Consumer/AccessManager/PNDataSyncProjectionScope.php +++ b/src/PubNub/Models/Consumer/AccessManager/PNDataSyncProjectionScope.php @@ -7,7 +7,7 @@ * scope ("res") or the pattern scope ("pat"). * * On the wire the scope is a flat map of composite keys - "datasync:entities:vehicle-1" mapped to - * a projection name - which this splits back into one map per resource family. + * a projection name - which this splits back into one map per record family. */ class PNDataSyncProjectionScope { @@ -16,6 +16,12 @@ class PNDataSyncProjectionScope /** @var array */ private array $entities; + /** @var array */ + private array $users; + + /** @var array */ + private array $channels; + /** @var array */ private array $relationships; @@ -24,12 +30,21 @@ class PNDataSyncProjectionScope /** * @param array $entities + * @param array $users + * @param array $channels * @param array $relationships * @param array $memberships */ - public function __construct(array $entities = [], array $relationships = [], array $memberships = []) - { + public function __construct( + array $entities = [], + array $users = [], + array $channels = [], + array $relationships = [], + array $memberships = [] + ) { $this->entities = $entities; + $this->users = $users; + $this->channels = $channels; $this->relationships = $relationships; $this->memberships = $memberships; } @@ -44,6 +59,26 @@ public function getEntities(): array return $this->entities; } + /** + * Identifier to projection name, for users. + * + * @return array + */ + public function getUsers(): array + { + return $this->users; + } + + /** + * Identifier to projection name, for channels. + * + * @return array + */ + public function getChannels(): array + { + return $this->channels; + } + /** * Identifier to projection name, for relationships. * @@ -72,6 +107,16 @@ public function getEntityProjection(string $id): ?string return $this->entities[$id] ?? null; } + public function getUserProjection(string $id): ?string + { + return $this->users[$id] ?? null; + } + + public function getChannelProjection(string $id): ?string + { + return $this->channels[$id] ?? null; + } + public function getRelationshipProjection(string $id): ?string { return $this->relationships[$id] ?? null; @@ -85,6 +130,8 @@ public function getMembershipProjection(string $id): ?string public function isEmpty(): bool { return count($this->entities) === 0 + && count($this->users) === 0 + && count($this->channels) === 0 && count($this->relationships) === 0 && count($this->memberships) === 0; } @@ -96,6 +143,8 @@ public function toArray(): array { return [ 'entities' => $this->entities, + 'users' => $this->users, + 'channels' => $this->channels, 'relationships' => $this->relationships, 'memberships' => $this->memberships, ]; @@ -109,9 +158,13 @@ public function toArray(): array */ public static function fromArray($scope): self { - $entities = []; - $relationships = []; - $memberships = []; + $families = [ + 'entities' => [], + 'users' => [], + 'channels' => [], + 'relationships' => [], + 'memberships' => [], + ]; if (!is_array($scope)) { return new self(); @@ -126,20 +179,18 @@ public static function fromArray($scope): self [$type, $id] = $parsed; - switch ($type) { - case 'entities': - $entities[$id] = (string) $projectionName; - break; - case 'relationships': - $relationships[$id] = (string) $projectionName; - break; - case 'memberships': - $memberships[$id] = (string) $projectionName; - break; + if (array_key_exists($type, $families)) { + $families[$type][$id] = (string) $projectionName; } } - return new self($entities, $relationships, $memberships); + return new self( + $families['entities'], + $families['users'], + $families['channels'], + $families['relationships'], + $families['memberships'] + ); } /** diff --git a/tests/bootstrap.php b/tests/bootstrap.php index b79f4470..4ca20fcd 100755 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -26,6 +26,7 @@ 'DATASYNC_PUBLISH_KEY', 'DATASYNC_SECRET_KEY', 'DATASYNC_ENTITY_CLASS', + 'DATASYNC_ENTITY_CLASS_PROJECTIONS', 'DATASYNC_ENTITY_RELATIONSHIP', 'DATASYNC_ORIGIN', ]; diff --git a/tests/functional/dataSync/DataSyncGrantTokenTest.php b/tests/functional/dataSync/DataSyncGrantTokenTest.php index 8243614b..fe527416 100644 --- a/tests/functional/dataSync/DataSyncGrantTokenTest.php +++ b/tests/functional/dataSync/DataSyncGrantTokenTest.php @@ -87,6 +87,35 @@ public function testProjectionsAreFlattenedIntoMeta(): void $this->assertSame('public', $projections['pat']['datasync:entities:^vehicle-.*$']); } + /** + * User and Channel records take their permissions from the shared uuid and channel scopes, + * but a projection still has to be assigned to them under their own family. + */ + public function testProjectionsCoverThePredefinedFamiliesToo(): void + { + $body = $this->body( + $this->pubnub->grantToken() + ->ttl(60) + ->addUuidResources(['user-1' => ['get' => true]]) + ->addChannelResources(['channel-1' => ['read' => true]]) + ->dataSyncProjections([ + 'resources' => [ + 'users' => ['user-1' => 'public'], + 'channels' => ['channel-1' => '__default__'], + 'relationships' => ['rel-1' => 'brief'], + ], + ]) + ); + + $projections = $body['permissions']['meta']['pn-projections']; + + $this->assertSame('public', $projections['res']['datasync:users:user-1']); + $this->assertSame('__default__', $projections['res']['datasync:channels:channel-1']); + $this->assertSame('brief', $projections['res']['datasync:relationships:rel-1']); + $this->assertSame(32, $body['permissions']['resources']['uuids']['user-1']); + $this->assertSame(1, $body['permissions']['resources']['channels']['channel-1']); + } + public function testProjectionsDoNotClobberUserSuppliedMeta(): void { $body = $this->body( diff --git a/tests/functional/dataSync/DataSyncRequestTest.php b/tests/functional/dataSync/DataSyncRequestTest.php index ecf72251..f87fdd04 100644 --- a/tests/functional/dataSync/DataSyncRequestTest.php +++ b/tests/functional/dataSync/DataSyncRequestTest.php @@ -6,6 +6,7 @@ use PubNub\Models\Consumer\DataSync\PNDataSyncPatch; use PubNub\PNConfiguration; use PubNub\PubNub; +use PubNub\PubNubUtil; use Psr\Http\Message\RequestInterface; /** @@ -149,6 +150,43 @@ public function testGetEntitiesRequest(): void $this->assertSame('createdAt:desc,status', $query['sort']); } + /** + * A signed request has to carry its query values encoded exactly once, because the signature + * is computed over that same single encoding. An endpoint that encodes its own values before + * handing them over would have them signed doubly encoded, and the server would answer 403. + */ + public function testASignedListRequestEncodesItsQueryOnlyOnce(): void + { + $config = new PNConfiguration(); + $config->setSubscribeKey('sub-key'); + $config->setPublishKey('pub-key'); + $config->setSecretKey('secret-key'); + $config->setUuid('datasync-request-uuid'); + + $request = (new PubNub($config))->dataSync()->getEntities() + ->entityClass('vehicle') + ->filter("payload.make == 'Toyota'") + ->sort(['createdAt' => 'desc']) + ->getRequest(); + + $raw = $request->getUri()->getQuery(); + + $this->assertStringContainsString('sort=createdAt%3Adesc', $raw); + $this->assertStringNotContainsString('%25', $raw, 'a %25 in the query means a value was encoded twice'); + + // Recomputed the way the server does it, from what actually went on the wire. + $params = $this->query($request); + unset($params['signature']); + + $expected = preg_replace('/=+$/', '', 'v2.' . PubNubUtil::signSha256( + 'secret-key', + "GET\npub-key\n" . $request->getUri()->getPath() . "\n" + . PubNubUtil::preparePamParams($params) . "\n" + )); + + $this->assertSame($expected, $this->query($request)['signature']); + } + public function testSetEntityRequest(): void { $request = $this->pubnub->dataSync()->setEntity() diff --git a/tests/integrational/dataSync/DataSyncGrantTest.php b/tests/integrational/dataSync/DataSyncGrantTest.php new file mode 100644 index 00000000..a1f12be0 --- /dev/null +++ b/tests/integrational/dataSync/DataSyncGrantTest.php @@ -0,0 +1,352 @@ +subscribeKey = getenv('DATASYNC_SUBSCRIBE_KEY') ?: ''; + $this->publishKey = getenv('DATASYNC_PUBLISH_KEY') ?: ''; + $secretKey = getenv('DATASYNC_SECRET_KEY') ?: ''; + $this->entityClass = getenv('DATASYNC_ENTITY_CLASS') ?: ''; + + if ( + $this->subscribeKey === '' || $this->publishKey === '' + || $secretKey === '' || $this->entityClass === '' + ) { + $this->markTestSkipped( + 'Set DATASYNC_SUBSCRIBE_KEY, DATASYNC_PUBLISH_KEY, DATASYNC_SECRET_KEY and ' + . 'DATASYNC_ENTITY_CLASS to run the DataSync grant tests against a live keyset.' + ); + } + + $config = $this->configuration('php-sdk-pam-admin'); + $config->setSecretKey($secretKey); + + $this->admin = new PubNub($config); + $this->createdEntityIds = []; + } + + public function tearDown(): void + { + foreach ($this->createdEntityIds as $entityId) { + try { + $this->admin->dataSync()->deleteEntity()->entityId($entityId)->sync(); + } catch (PubNubServerException $exception) { + // best-effort cleanup + } + } + + parent::tearDown(); + } + + private function configuration(string $uuid): PNConfiguration + { + $config = new PNConfiguration(); + $config->setSubscribeKey($this->subscribeKey); + $config->setPublishKey($this->publishKey); + $config->setUuid($uuid); + + if ($origin = getenv('DATASYNC_ORIGIN')) { + $config->setOrigin($origin); + } + + return $config; + } + + /** + * A client whose only credential is the token, so anything it can do, the token allows. + */ + private function clientWithToken(string $token): PubNub + { + $client = new PubNub($this->configuration(self::CLIENT_UUID)); + $client->setToken($token); + + return $client; + } + + /** + * @param callable(GrantToken): void $configure + */ + private function grant(callable $configure): string + { + $endpoint = $this->admin->grantToken() + ->ttl(60) + ->authorizedUuid(self::CLIENT_UUID); + + $configure($endpoint); + + $token = $endpoint->sync(); + $this->assertNotEmpty($token); + + sleep(self::TOKEN_PROPAGATION_SECONDS); + + return $token; + } + + private function createEntity(?string $entityId = null): string + { + $entityId = $entityId ?? 'php-sdk-pam-' . uniqid(); + + $this->admin->dataSync()->createEntity() + ->entityId($entityId) + ->entityClass($this->entityClass) + ->entityClassVersion(1) + ->status('active') + ->payload(['make' => 'Toyota']) + ->sync(); + + $this->createdEntityIds[] = $entityId; + + return $entityId; + } + + /** + * @param callable $call + */ + private function assertDenied(callable $call, string $message): void + { + try { + $call(); + } catch (PubNubServerException $exception) { + $this->assertSame(403, $exception->getStatusCode(), $message . ' (wrong status code)'); + return; + } + + $this->fail($message); + } + + /** + * @param Permissions|false $granted + */ + private function granted($granted, string $message): Permissions + { + $this->assertInstanceOf(Permissions::class, $granted, $message); + + /** @var Permissions $granted */ + return $granted; + } + + public function testEntityResourceGrantAllowsOnlyTheGrantedEntity(): void + { + $grantedId = $this->createEntity(); + $otherId = $this->createEntity(); + + $token = $this->grant(function (GrantToken $grant) use ($grantedId): void { + $grant->addDataSyncEntityResources([$grantedId => ['get' => true]]); + }); + + $client = $this->clientWithToken($token); + + $allowed = $client->dataSync()->getEntity()->entityId($grantedId)->sync(); + $this->assertSame($grantedId, $allowed->getId()); + + $this->assertDenied( + fn() => $client->dataSync()->getEntity()->entityId($otherId)->sync(), + 'an entity outside the grant must not be readable' + ); + } + + public function testEntityPatternGrantAllowsOnlyMatchingIds(): void + { + $prefix = 'phppam' . substr(uniqid(), -8); + $matchingId = $this->createEntity($prefix . '-match'); + $nonMatchingId = $this->createEntity(); + + $token = $this->grant(function (GrantToken $grant) use ($prefix): void { + $grant->addDataSyncEntityPatterns(['^' . $prefix . '-.*$' => ['get' => true]]); + }); + + $client = $this->clientWithToken($token); + + $allowed = $client->dataSync()->getEntity()->entityId($matchingId)->sync(); + $this->assertSame($matchingId, $allowed->getId()); + + $this->assertDenied( + fn() => $client->dataSync()->getEntity()->entityId($nonMatchingId)->sync(), + 'an id the pattern does not match must not be readable' + ); + } + + public function testGetOnlyGrantDeniesWrites(): void + { + $entityId = $this->createEntity(); + + $token = $this->grant(function (GrantToken $grant) use ($entityId): void { + $grant->addDataSyncEntityResources([$entityId => ['get' => true]]); + }); + + $client = $this->clientWithToken($token); + + $read = $client->dataSync()->getEntity()->entityId($entityId)->sync(); + $this->assertSame('active', $read->getData()->getStatus()); + + $this->assertDenied( + fn() => $client->dataSync()->setEntity() + ->entityId($entityId) + ->entityClassVersion(1) + ->status('updated') + ->payload(['make' => 'Honda']) + ->sync(), + 'a get-only grant must not allow a replace' + ); + + $this->assertDenied( + fn() => $client->dataSync()->deleteEntity()->entityId($entityId)->sync(), + 'a get-only grant must not allow a delete' + ); + } + + public function testUpdateGrantAllowsWritingTheGrantedEntity(): void + { + $entityId = $this->createEntity(); + + $token = $this->grant(function (GrantToken $grant) use ($entityId): void { + $grant->addDataSyncEntityResources([$entityId => ['get' => true, 'update' => true]]); + }); + + $client = $this->clientWithToken($token); + + $updated = $client->dataSync()->setEntity() + ->entityId($entityId) + ->entityClassVersion(1) + ->status('updated') + ->payload(['make' => 'Honda']) + ->sync(); + + $this->assertSame('updated', $updated->getData()->getStatus()); + + // Update does not imply delete, so the narrower operation is still refused. + $this->assertDenied( + fn() => $client->dataSync()->deleteEntity()->entityId($entityId)->sync(), + 'update permission must not imply delete' + ); + } + + /** + * The projection travels in the token's meta rather than its permissions, so the proof that + * the SDK encodes it the way Access Manager expects is that the data plane accepts the token. + */ + public function testProjectionScopedGrantIsAcceptedByTheDataPlane(): void + { + $entityId = $this->createEntity(); + + $token = $this->grant(function (GrantToken $grant) use ($entityId): void { + $grant + ->addDataSyncEntityResources([$entityId => ['get' => true]]) + ->dataSyncProjections([ + 'resources' => ['entities' => [$entityId => '__default__']], + ]); + }); + + $client = $this->clientWithToken($token); + $read = $client->dataSync()->getEntity()->entityId($entityId)->sync(); + + $this->assertSame($entityId, $read->getId()); + $this->assertSame(['make' => 'Toyota'], $read->getData()->getPayload()); + + $projections = $this->admin->parseToken($token)->getDataSyncProjections(); + $this->assertNotNull($projections); + $this->assertSame('__default__', $projections->getResources()->getEntityProjection($entityId)); + } + + /** + * Everything the SDK can put into a token has to survive the round trip through the service + * and back out of parseToken(), including the scopes and projection families the data plane + * tests above never touch. + */ + public function testGrantedTokenParsesBackIntoItsDataSyncScopes(): void + { + $token = $this->grant(function (GrantToken $grant): void { + $grant + ->addDataSyncEntityResources(['vehicle-1' => ['get' => true, 'update' => true]]) + ->addDataSyncRelationshipResources(['rel-1' => ['get' => true]]) + ->addDataSyncMembershipResources(['mem-1' => ['get' => true, 'delete' => true]]) + ->addDataSyncEntityPatterns(['^vehicle-.*$' => ['get' => true]]) + ->addUuidResources(['user-1' => ['get' => true]]) + ->dataSyncProjections([ + 'resources' => [ + 'entities' => ['vehicle-1' => '__default__'], + 'users' => ['user-1' => '__default__'], + 'memberships' => ['mem-1' => '__default__'], + ], + 'patterns' => [ + 'entities' => ['^vehicle-.*$' => '__default__'], + ], + ]); + }); + + $parsed = $this->admin->parseToken($token); + + $this->assertSame(self::CLIENT_UUID, $parsed->getUuid()); + + $entity = $this->granted($parsed->getDataSyncEntityResource('vehicle-1'), 'entity resource'); + $this->assertTrue($entity->hasGet()); + $this->assertTrue($entity->hasUpdate()); + $this->assertFalse($entity->hasDelete()); + + $this->assertTrue($this->granted( + $parsed->getDataSyncRelationshipResource('rel-1'), + 'relationship resource' + )->hasGet()); + + $membership = $this->granted($parsed->getDataSyncMembershipResource('mem-1'), 'membership resource'); + $this->assertTrue($membership->hasGet()); + $this->assertTrue($membership->hasDelete()); + + $this->assertTrue($this->granted( + $parsed->getDataSyncEntityPattern('^vehicle-.*$'), + 'entity pattern' + )->hasGet()); + + $this->assertFalse($parsed->getDataSyncEntityResource('vehicle-2')); + + $projections = $parsed->getDataSyncProjections(); + $this->assertNotNull($projections); + + $resources = $projections->getResources(); + $this->assertSame('__default__', $resources->getEntityProjection('vehicle-1')); + $this->assertSame('__default__', $resources->getUserProjection('user-1')); + $this->assertSame('__default__', $resources->getMembershipProjection('mem-1')); + $this->assertSame('__default__', $projections->getPatterns()->getEntityProjection('^vehicle-.*$')); + } +} diff --git a/tests/integrational/dataSync/DataSyncPredefinedLifecycleTest.php b/tests/integrational/dataSync/DataSyncPredefinedLifecycleTest.php new file mode 100644 index 00000000..40e290fa --- /dev/null +++ b/tests/integrational/dataSync/DataSyncPredefinedLifecycleTest.php @@ -0,0 +1,340 @@ +markTestSkipped( + 'Set DATASYNC_SUBSCRIBE_KEY and DATASYNC_PUBLISH_KEY to run the DataSync predefined ' + . 'lifecycle test against a live keyset.' + ); + } + + $config = new PNConfiguration(); + $config->setSubscribeKey($subscribeKey); + $config->setPublishKey($publishKey); + $config->setUuid('datasync-predefined-lifecycle-test'); + + $secretKey = getenv('DATASYNC_SECRET_KEY') ?: ''; + + if ($secretKey !== '') { + $config->setSecretKey($secretKey); + } + + if ($origin = getenv('DATASYNC_ORIGIN')) { + $config->setOrigin($origin); + } + + $this->pubnub = new PubNub($config); + } + + public function testUserLifecycle(): void + { + $userId = 'php-sdk-user-' . uniqid(); + + $created = $this->pubnub->dataSync()->createUser() + ->userId($userId) + ->entityClassVersion(1) + ->status('active') + ->payload(['name' => 'Alice']) + ->sync(); + + $this->assertSame($userId, $created->getId()); + $this->assertNotEmpty($created->getETag()); + + try { + $fetched = $this->pubnub->dataSync()->getUser()->userId($userId)->sync(); + + $this->assertSame($userId, $fetched->getId()); + $this->assertSame('active', $fetched->getData()->getStatus()); + $this->assertSame(['name' => 'Alice'], $fetched->getData()->getPayload()); + + // The class was never sent, so whatever comes back is the server's own. + $this->assertStringContainsStringIgnoringCase( + 'user', + (string) $fetched->getData()->getEntityClass() + ); + + $patched = $this->pubnub->dataSync()->updateUser() + ->userId($userId) + ->ifMatchesETag((string) $fetched->getETag()) + ->patch( + (new PNDataSyncPatch()) + ->replace('/status', 'inactive') + ->add('/payload/nickname', 'Ali') + ) + ->sync(); + + $this->assertSame('inactive', $patched->getData()->getStatus()); + // Key order is the server's business, so compare by content. + $this->assertEquals(['name' => 'Alice', 'nickname' => 'Ali'], $patched->getData()->getPayload()); + + // PUT replaces the record outright, so the patched field is gone again. + $replaced = $this->pubnub->dataSync()->setUser() + ->userId($userId) + ->entityClassVersion(1) + ->ifMatchesETag((string) $patched->getETag()) + ->status('archived') + ->payload(['name' => 'Alice Cooper']) + ->sync(); + + $this->assertSame('archived', $replaced->getData()->getStatus()); + $this->assertSame(['name' => 'Alice Cooper'], $replaced->getData()->getPayload()); + + $this->assertContains($userId, $this->listedUserIds()); + } finally { + $deleted = $this->pubnub->dataSync()->deleteUser()->userId($userId)->sync(); + $this->assertTrue($deleted->isSuccess()); + } + } + + public function testChannelLifecycle(): void + { + $channelId = 'php-sdk-channel-' . uniqid(); + + $created = $this->pubnub->dataSync()->createChannel() + ->channelId($channelId) + ->entityClassVersion(1) + ->status('active') + ->payload(['name' => 'Support']) + ->sync(); + + $this->assertSame($channelId, $created->getId()); + $this->assertNotEmpty($created->getETag()); + + try { + $fetched = $this->pubnub->dataSync()->getChannel()->channelId($channelId)->sync(); + + $this->assertSame($channelId, $fetched->getId()); + $this->assertSame('active', $fetched->getData()->getStatus()); + $this->assertSame(['name' => 'Support'], $fetched->getData()->getPayload()); + $this->assertStringContainsStringIgnoringCase( + 'channel', + (string) $fetched->getData()->getEntityClass() + ); + + $patched = $this->pubnub->dataSync()->updateChannel() + ->channelId($channelId) + ->ifMatchesETag((string) $fetched->getETag()) + ->patch( + (new PNDataSyncPatch()) + ->replace('/status', 'muted') + ->add('/payload/topic', 'billing') + ) + ->sync(); + + $this->assertSame('muted', $patched->getData()->getStatus()); + $this->assertEquals(['name' => 'Support', 'topic' => 'billing'], $patched->getData()->getPayload()); + + $replaced = $this->pubnub->dataSync()->setChannel() + ->channelId($channelId) + ->entityClassVersion(1) + ->ifMatchesETag((string) $patched->getETag()) + ->status('archived') + ->payload(['name' => 'Support (retired)']) + ->sync(); + + $this->assertSame('archived', $replaced->getData()->getStatus()); + $this->assertSame(['name' => 'Support (retired)'], $replaced->getData()->getPayload()); + + $this->assertContains($channelId, $this->listedChannelIds()); + } finally { + $deleted = $this->pubnub->dataSync()->deleteChannel()->channelId($channelId)->sync(); + $this->assertTrue($deleted->isSuccess()); + } + } + + /** + * A membership is the predefined relationship between a channel and a user, so the same round + * trip also has to show that the two links survive a replacement that never sends them, and + * that the record can be listed from either side. + */ + public function testMembershipLifecycle(): void + { + $channelId = $this->createChannel(); + $userId = $this->createUser(); + $membershipId = 'php-sdk-mem-' . uniqid(); + + try { + $created = $this->pubnub->dataSync()->createMembership() + ->membershipId($membershipId) + ->channelId($channelId) + ->userId($userId) + ->relationshipClassVersion(1) + ->status('active') + ->payload(['role' => 'member', 'joinedAt' => '2025-01-01']) + ->sync(); + + $this->assertSame($membershipId, $created->getId()); + $this->assertNotEmpty($created->getETag()); + + try { + $fetched = $this->pubnub->dataSync()->getMembership() + ->membershipId($membershipId) + ->sync(); + + $this->assertSame($membershipId, $fetched->getId()); + $this->assertSame($channelId, $fetched->getData()->getChannelId()); + $this->assertSame($userId, $fetched->getData()->getUserId()); + $this->assertSame('active', $fetched->getData()->getStatus()); + $this->assertStringContainsStringIgnoringCase( + 'membership', + (string) $fetched->getData()->getRelationshipClass() + ); + + // PUT carries neither the class nor the two links, which must survive it anyway. + $replaced = $this->pubnub->dataSync()->setMembership() + ->membershipId($membershipId) + ->relationshipClassVersion(1) + ->ifMatchesETag((string) $fetched->getETag()) + ->status('updated') + ->payload(['role' => 'admin']) + ->sync(); + + $this->assertSame('updated', $replaced->getData()->getStatus()); + $this->assertSame(['role' => 'admin'], $replaced->getData()->getPayload()); + $this->assertSame($channelId, $replaced->getData()->getChannelId()); + $this->assertSame($userId, $replaced->getData()->getUserId()); + + $patched = $this->pubnub->dataSync()->updateMembership() + ->membershipId($membershipId) + ->ifMatchesETag((string) $replaced->getETag()) + ->patch( + (new PNDataSyncPatch()) + ->replace('/status', 'patched') + ->add('/payload/note', 'promoted') + ) + ->sync(); + + $this->assertSame('patched', $patched->getData()->getStatus()); + $this->assertEquals(['role' => 'admin', 'note' => 'promoted'], $patched->getData()->getPayload()); + + $this->assertContains( + $membershipId, + $this->listedMembershipIds(['userId' => $userId]), + 'the membership should be listed among the channels the user belongs to' + ); + $this->assertContains( + $membershipId, + $this->listedMembershipIds(['channelId' => $channelId]), + 'and among the members of the channel' + ); + } finally { + $deleted = $this->pubnub->dataSync()->deleteMembership() + ->membershipId($membershipId) + ->sync(); + $this->assertTrue($deleted->isSuccess()); + } + } finally { + $this->pubnub->dataSync()->deleteUser()->userId($userId)->sync(); + $this->pubnub->dataSync()->deleteChannel()->channelId($channelId)->sync(); + } + } + + /** + * Newest first, so the record written moments ago is on the first page however many the keyset + * has accumulated. + * + * @return string[] + */ + private function listedUserIds(): array + { + $listed = $this->pubnub->dataSync()->getUsers() + ->limit(100) + ->sort(['createdAt' => 'desc']) + ->sync(); + + $this->assertNotNull($listed->getPage()); + + return array_map(static fn($user) => $user->getId(), $listed->getData()); + } + + /** + * @return string[] + */ + private function listedChannelIds(): array + { + $listed = $this->pubnub->dataSync()->getChannels() + ->limit(100) + ->sort(['createdAt' => 'desc']) + ->sync(); + + $this->assertNotNull($listed->getPage()); + + return array_map(static fn($channel) => $channel->getId(), $listed->getData()); + } + + /** + * @param array{userId?: string, channelId?: string} $side + * @return string[] + */ + private function listedMembershipIds(array $side): array + { + $endpoint = $this->pubnub->dataSync()->getMemberships()->limit(100); + + if (isset($side['userId'])) { + $endpoint->userId($side['userId']); + } + + if (isset($side['channelId'])) { + $endpoint->channelId($side['channelId']); + } + + return array_map(static fn($membership) => $membership->getId(), $endpoint->sync()->getData()); + } + + private function createUser(): string + { + $userId = 'php-sdk-user-' . uniqid(); + + $this->pubnub->dataSync()->createUser() + ->userId($userId) + ->entityClassVersion(1) + ->status('active') + ->payload(['name' => 'user-' . $userId]) + ->sync(); + + return $userId; + } + + private function createChannel(): string + { + $channelId = 'php-sdk-channel-' . uniqid(); + + $this->pubnub->dataSync()->createChannel() + ->channelId($channelId) + ->entityClassVersion(1) + ->status('active') + ->payload(['name' => 'channel-' . $channelId]) + ->sync(); + + return $channelId; + } +} diff --git a/tests/integrational/dataSync/DataSyncProjectionTest.php b/tests/integrational/dataSync/DataSyncProjectionTest.php new file mode 100644 index 00000000..575cad6f --- /dev/null +++ b/tests/integrational/dataSync/DataSyncProjectionTest.php @@ -0,0 +1,264 @@ + 'Camry', 'owner' => 'Alice']; + + /** Fields only the "admin" projection exposes. */ + private const ADMIN_ONLY_FIELDS = ['dateBought' => '2024-01-01', 'comments' => 'first owner']; + + private PubNub $admin; + + private string $subscribeKey; + + private string $publishKey; + + private string $entityClass; + + /** @var string[] */ + private array $createdEntityIds = []; + + public function setUp(): void + { + parent::setUp(); + + $this->subscribeKey = getenv('DATASYNC_SUBSCRIBE_KEY') ?: ''; + $this->publishKey = getenv('DATASYNC_PUBLISH_KEY') ?: ''; + $secretKey = getenv('DATASYNC_SECRET_KEY') ?: ''; + $this->entityClass = getenv('DATASYNC_ENTITY_CLASS_PROJECTIONS') ?: ''; + + if ( + $this->subscribeKey === '' || $this->publishKey === '' + || $secretKey === '' || $this->entityClass === '' + ) { + $this->markTestSkipped( + 'Set DATASYNC_SUBSCRIBE_KEY, DATASYNC_PUBLISH_KEY, DATASYNC_SECRET_KEY and ' + . 'DATASYNC_ENTITY_CLASS_PROJECTIONS to run the DataSync projection tests.' + ); + } + + $config = $this->configuration('php-sdk-projection-admin'); + $config->setSecretKey($secretKey); + + $this->admin = new PubNub($config); + $this->createdEntityIds = []; + } + + public function tearDown(): void + { + foreach ($this->createdEntityIds as $entityId) { + try { + $this->admin->dataSync()->deleteEntity()->entityId($entityId)->sync(); + } catch (PubNubServerException $exception) { + // best-effort cleanup + } + } + + parent::tearDown(); + } + + private function configuration(string $uuid): PNConfiguration + { + $config = new PNConfiguration(); + $config->setSubscribeKey($this->subscribeKey); + $config->setPublishKey($this->publishKey); + $config->setUuid($uuid); + + if ($origin = getenv('DATASYNC_ORIGIN')) { + $config->setOrigin($origin); + } + + return $config; + } + + /** + * Creates a vehicle through the full-access admin with every projected field populated, so + * both projections have something to reveal or hide. + */ + private function createVehicle(?string $entityId = null): string + { + $entityId = $entityId ?? 'php-sdk-projection-' . uniqid(); + + $this->admin->dataSync()->createEntity() + ->entityId($entityId) + ->entityClass($this->entityClass) + ->entityClassVersion(1) + ->status('active') + ->payload(array_merge(self::PROJECTED_FIELDS, self::ADMIN_ONLY_FIELDS)) + ->sync(); + + $this->createdEntityIds[] = $entityId; + + return $entityId; + } + + /** + * @param callable(GrantToken): void $configure + */ + private function clientWithGrant(callable $configure): PubNub + { + $endpoint = $this->admin->grantToken() + ->ttl(60) + ->authorizedUuid(self::CLIENT_UUID); + + $configure($endpoint); + + $client = new PubNub($this->configuration(self::CLIENT_UUID)); + $client->setToken($endpoint->sync()); + + sleep(self::TOKEN_PROPAGATION_SECONDS); + + return $client; + } + + /** + * Grants get on one entity, seen through one projection. + * + * @param array $permissions + */ + private function clientSeeing( + string $entityId, + string $projection, + array $permissions = ['get' => true] + ): PubNub { + return $this->clientWithGrant(function (GrantToken $grant) use ($entityId, $projection, $permissions): void { + $grant + ->addDataSyncEntityResources([$entityId => $permissions]) + ->dataSyncProjections([ + 'resources' => ['entities' => [$entityId => $projection]], + ]); + }); + } + + /** + * @return array + */ + private function readPayload(PubNub $client, string $entityId): array + { + $payload = $client->dataSync()->getEntity()->entityId($entityId)->sync()->getData()->getPayload(); + $this->assertNotNull($payload); + + return $payload; + } + + public function testDefaultProjectionHidesTheAdminOnlyFields(): void + { + $entityId = $this->createVehicle(); + $client = $this->clientSeeing($entityId, self::DEFAULT_PROJECTION); + + $payload = $this->readPayload($client, $entityId); + + foreach (self::PROJECTED_FIELDS as $field => $value) { + $this->assertSame($value, $payload[$field] ?? null, "$field belongs to the base projection"); + } + + foreach (array_keys(self::ADMIN_ONLY_FIELDS) as $field) { + $this->assertArrayNotHasKey($field, $payload, "$field is admin-only"); + } + } + + public function testAdminProjectionRevealsEveryField(): void + { + $entityId = $this->createVehicle(); + $client = $this->clientSeeing($entityId, self::ADMIN_PROJECTION); + + $payload = $this->readPayload($client, $entityId); + + foreach (array_merge(self::PROJECTED_FIELDS, self::ADMIN_ONLY_FIELDS) as $field => $value) { + $this->assertSame($value, $payload[$field] ?? null, "$field should be visible to admin"); + } + } + + public function testPatternProjectionAppliesToMatchingIds(): void + { + $prefix = 'phpproj' . substr(uniqid(), -8); + $entityId = $this->createVehicle($prefix . '-match'); + $pattern = '^' . $prefix . '-.*$'; + + $client = $this->clientWithGrant(function (GrantToken $grant) use ($pattern): void { + $grant + ->addDataSyncEntityPatterns([$pattern => ['get' => true]]) + ->dataSyncProjections([ + 'patterns' => ['entities' => [$pattern => self::DEFAULT_PROJECTION]], + ]); + }); + + $payload = $this->readPayload($client, $entityId); + + $this->assertArrayHasKey('model', $payload); + $this->assertArrayNotHasKey('dateBought', $payload, 'the pattern projection still hides admin fields'); + } + + public function testDefaultProjectionCanWriteTheFieldsItSees(): void + { + $entityId = $this->createVehicle(); + $client = $this->clientSeeing($entityId, self::DEFAULT_PROJECTION, ['get' => true, 'update' => true]); + + $client->dataSync()->updateEntity() + ->entityId($entityId) + ->patch((new PNDataSyncPatch())->replace('/payload/model', 'UpdatedModel')) + ->sync(); + + // Read back through the admin, which sees everything: the visible field changed and the + // fields the client could not see survived the write untouched. + $full = $this->admin->dataSync()->getEntity()->entityId($entityId)->sync()->getData()->getPayload(); + + $this->assertSame('UpdatedModel', $full['model'] ?? null); + + foreach (self::ADMIN_ONLY_FIELDS as $field => $value) { + $this->assertSame($value, $full[$field] ?? null, "$field must survive a base-projection write"); + } + } + + public function testDefaultProjectionCannotWriteAnAdminOnlyField(): void + { + $entityId = $this->createVehicle(); + $client = $this->clientSeeing($entityId, self::DEFAULT_PROJECTION, ['get' => true, 'update' => true]); + + try { + $client->dataSync()->updateEntity() + ->entityId($entityId) + ->patch((new PNDataSyncPatch())->replace('/payload/dateBought', '2030-12-31')) + ->sync(); + } catch (PubNubServerException $exception) { + // Refusing the write outright is one valid outcome; dropping the invisible field from + // an otherwise accepted request is the other. The check below covers both. + } + + $full = $this->admin->dataSync()->getEntity()->entityId($entityId)->sync()->getData()->getPayload(); + + $this->assertSame( + self::ADMIN_ONLY_FIELDS['dateBought'], + $full['dateBought'] ?? null, + 'writing an admin-only field through the base projection must not persist' + ); + } +} diff --git a/tests/unit/dataSync/DataSyncTokenParseTest.php b/tests/unit/dataSync/DataSyncTokenParseTest.php index e1b1fe42..80cd8c8f 100644 --- a/tests/unit/dataSync/DataSyncTokenParseTest.php +++ b/tests/unit/dataSync/DataSyncTokenParseTest.php @@ -134,6 +134,26 @@ public function testProjectionsAreSplitBackIntoResourceFamilies(): void $this->assertSame([], $patterns->getMemberships()); } + public function testPredefinedProjectionFamiliesAreSplitOutToo(): void + { + $resources = $this->projections($this->token([ + 'meta' => [ + 'pn-projections' => [ + 'res' => [ + 'datasync:users:user-1' => 'public', + 'datasync:channels:channel-1' => '__default__', + ], + ], + ], + ]))->getResources(); + + $this->assertSame(['user-1' => 'public'], $resources->getUsers()); + $this->assertSame(['channel-1' => '__default__'], $resources->getChannels()); + $this->assertSame('public', $resources->getUserProjection('user-1')); + $this->assertSame('__default__', $resources->getChannelProjection('channel-1')); + $this->assertSame([], $resources->getEntities()); + } + public function testProjectionIdentifiersMayContainColons(): void { $scope = PNDataSyncProjectionScope::fromArray([ From 4cf43625641eb43aa508ad5c63cbb01635b51592 Mon Sep 17 00:00:00 2001 From: "PUBNUB\\jakub.grzesiowski" Date: Mon, 21 Sep 2026 15:14:43 +0200 Subject: [PATCH 4/8] Initial review fixes --- examples/DataSync.php | 10 +- .../Endpoints/DataSync/DataSyncEndpoint.php | 3 + src/PubNub/Endpoints/Endpoint.php | 11 +- .../Exceptions/PubNubServerException.php | 4 + .../dataSync/DataSyncEventTest.php | 38 ++++ .../dataSync/DataSyncGrantTest.php | 21 ++ .../dataSync/DataSyncLifecycleTest.php | 213 +++++++++++++++++- tests/unit/dataSync/DataSyncErrorTest.php | 61 +++++ 8 files changed, 352 insertions(+), 9 deletions(-) create mode 100644 tests/unit/dataSync/DataSyncErrorTest.php diff --git a/examples/DataSync.php b/examples/DataSync.php index dfd6b745..3f851c7c 100644 --- a/examples/DataSync.php +++ b/examples/DataSync.php @@ -268,12 +268,14 @@ public function dataSyncEvent($pubnub, $event) $admin = new PubNub($adminConfig); + // DataSync reads are gated on "get" and writes on "update". The read and write bits that App + // Context grants are not what it looks at, so a token granted those permits nothing here. // A projection names the subset of fields the holder may see. "__default__" is the full record. $token = $admin->grantToken() ->ttl(60) ->authorizedUuid($userId) - ->addDataSyncEntityResources([$vehicleId => ['read' => true, 'update' => true]]) - ->addDataSyncMembershipPatterns(['^' . $userId . ':.*$' => ['read' => true]]) + ->addDataSyncEntityResources([$vehicleId => ['get' => true, 'update' => true]]) + ->addDataSyncMembershipPatterns(['^' . $userId . ':.*$' => ['get' => true]]) ->dataSyncProjections([ 'resources' => ['entities' => [$vehicleId => '__default__']], 'patterns' => ['memberships' => ['^' . $userId . ':.*$' => 'summary']], @@ -285,9 +287,9 @@ public function dataSyncEvent($pubnub, $event) $vehiclePermissions = $parsed->getDataSyncEntityResource($vehicleId); printf( - "%s: read=%s update=%s delete=%s\n", + "%s: get=%s update=%s delete=%s\n", $vehicleId, - var_export($vehiclePermissions->hasRead(), true), + var_export($vehiclePermissions->hasGet(), true), var_export($vehiclePermissions->hasUpdate(), true), var_export($vehiclePermissions->hasDelete(), true) ); diff --git a/src/PubNub/Endpoints/DataSync/DataSyncEndpoint.php b/src/PubNub/Endpoints/DataSync/DataSyncEndpoint.php index 225686f6..321c1502 100644 --- a/src/PubNub/Endpoints/DataSync/DataSyncEndpoint.php +++ b/src/PubNub/Endpoints/DataSync/DataSyncEndpoint.php @@ -27,6 +27,9 @@ abstract class DataSyncEndpoint extends Endpoint protected const PATCH_MEDIA_TYPE = "application/json-patch+json"; + /** A delete answers 200 with no body. */ + protected const RESPONSE_MAY_BE_EMPTY = true; + protected bool $endpointAuthRequired = true; /** Identifier of the addressed resource; null for create and list operations. */ diff --git a/src/PubNub/Endpoints/Endpoint.php b/src/PubNub/Endpoints/Endpoint.php index 94869cf6..5b404a84 100755 --- a/src/PubNub/Endpoints/Endpoint.php +++ b/src/PubNub/Endpoints/Endpoint.php @@ -35,6 +35,12 @@ abstract class Endpoint protected const RESPONSE_IS_JSON = true; + /** + * Whether a successful response is allowed to carry no body at all. Off by default, so an + * endpoint that has always answered with JSON still reports an empty body as a parse error. + */ + protected const RESPONSE_MAY_BE_EMPTY = false; + /** @var PubNub */ protected $pubnub; @@ -451,8 +457,9 @@ public function parseResponse(ResponseInterface $response): PNEnvelope if ($statusCode >= 200 && $statusCode < 300) { $contents = $response->getBody()->getContents(); if (static::RESPONSE_IS_JSON) { - // A successful DELETE answers 200 with no body at all, which json_decode() reports as a syntax error. - if (trim($contents) === '') { + // A successful DataSync delete answers 200 with no body at all, which json_decode() + // reports as a syntax error. + if (static::RESPONSE_MAY_BE_EMPTY && trim($contents) === '') { $result = $this->createResponse([]); } else { $parsedJSON = json_decode($contents, true); diff --git a/src/PubNub/Exceptions/PubNubServerException.php b/src/PubNub/Exceptions/PubNubServerException.php index 05fea378..e1ac039a 100644 --- a/src/PubNub/Exceptions/PubNubServerException.php +++ b/src/PubNub/Exceptions/PubNubServerException.php @@ -85,6 +85,10 @@ public function getServerErrorMessage() return $this->body->error->message; } elseif (isset($this->body->message)) { return $this->body->message; + } elseif (isset($this->body->errors[0]->message)) { + // DataSync reports a list of errors rather than a single one. The rest of the list, + // and the DS-xxxx code on each entry, stay reachable through getBody(). + return $this->body->errors[0]->message; } else { return null; } diff --git a/tests/integrational/dataSync/DataSyncEventTest.php b/tests/integrational/dataSync/DataSyncEventTest.php index 700b1062..1934ef9f 100644 --- a/tests/integrational/dataSync/DataSyncEventTest.php +++ b/tests/integrational/dataSync/DataSyncEventTest.php @@ -262,6 +262,44 @@ public function testPredefinedUserEvents(): void $this->assertNotEmpty($deleted->getDeletedAt()); } + /** + * Channel is the other predefined entity class, and nothing but the type distinguishes its + * events from a user's. + */ + public function testPredefinedChannelEvents(): void + { + $channelId = 'php-sdk-event-channel-' . uniqid(); + + $events = $this->captureEvents($channelId, 2, function () use ($channelId) { + $this->pubnub->dataSync()->createChannel() + ->channelId($channelId) + ->entityClassVersion(1) + ->status('active') + ->payload(['name' => 'Support']) + ->sync(); + + $this->pubnub->dataSync()->deleteChannel()->channelId($channelId)->sync(); + }); + + $created = $this->eventNamed($events, 'create'); + + $this->assertSame('channel', strtolower((string) $created->getType())); + $this->assertSame($channelId, $created->getChannel()); + $this->assertNotEmpty($created->getClassName()); + $this->assertNotEmpty($created->getClassLevel()); + $this->assertNull($created->getRelationship()); + + $channel = $created->getEntity(); + $this->assertNotNull($channel); + $this->assertSame($channelId, $channel->getId()); + $this->assertSame('active', $channel->getStatus()); + $this->assertSame(['name' => 'Support'], $channel->getPayload()); + + $deleted = $this->eventNamed($events, 'delete'); + $this->assertSame($channelId, $deleted->getId()); + $this->assertNotEmpty($deleted->getDeletedAt()); + } + /** * Membership is a predefined relationship class between a Channel and a User, and its events * travel on the channels of both records it links. diff --git a/tests/integrational/dataSync/DataSyncGrantTest.php b/tests/integrational/dataSync/DataSyncGrantTest.php index a1f12be0..7b886cec 100644 --- a/tests/integrational/dataSync/DataSyncGrantTest.php +++ b/tests/integrational/dataSync/DataSyncGrantTest.php @@ -186,6 +186,27 @@ public function testEntityResourceGrantAllowsOnlyTheGrantedEntity(): void ); } + /** + * DataSync gates reads on "get" and writes on "update", not on the read and write bits App + * Context is granted with. The two are easy to mix up because the same builder takes both, and + * getting it wrong produces a token that looks generous and permits nothing. + */ + public function testAppContextStylePermissionsGrantNoDataSyncAccess(): void + { + $entityId = $this->createEntity(); + + $token = $this->grant(function (GrantToken $grant) use ($entityId): void { + $grant->addDataSyncEntityResources([$entityId => ['read' => true, 'write' => true]]); + }); + + $client = $this->clientWithToken($token); + + $this->assertDenied( + fn() => $client->dataSync()->getEntity()->entityId($entityId)->sync(), + 'read must not stand in for get' + ); + } + public function testEntityPatternGrantAllowsOnlyMatchingIds(): void { $prefix = 'phppam' . substr(uniqid(), -8); diff --git a/tests/integrational/dataSync/DataSyncLifecycleTest.php b/tests/integrational/dataSync/DataSyncLifecycleTest.php index 44793334..65495ed1 100644 --- a/tests/integrational/dataSync/DataSyncLifecycleTest.php +++ b/tests/integrational/dataSync/DataSyncLifecycleTest.php @@ -131,6 +131,213 @@ public function testEntityLifecycle(): void } } + /** + * Every operation the patch builder can emit, against the live server. The lifecycle tests only + * ever replace and add, so remove, move, copy and test are otherwise proven no further than the + * wire format. + */ + public function testPatchSupportsEveryOperation(): void + { + $entityId = $this->createEntity(); + + try { + $seeded = $this->pubnub->dataSync()->setEntity() + ->entityId($entityId) + ->entityClassVersion(1) + ->status('active') + ->payload(['make' => 'Toyota', 'model' => 'Camry', 'scratch' => 'temporary']) + ->sync(); + + $patched = $this->pubnub->dataSync()->updateEntity() + ->entityId($entityId) + ->ifMatchesETag((string) $seeded->getETag()) + ->patch( + (new PNDataSyncPatch()) + ->test('/payload/make', 'Toyota') + ->copy('/payload/model', '/payload/previousModel') + ->move('/payload/scratch', '/payload/note') + ->remove('/payload/model') + ->replace('/status', 'patched') + ) + ->sync(); + + $this->assertSame('patched', $patched->getData()->getStatus()); + $this->assertEquals( + ['make' => 'Toyota', 'previousModel' => 'Camry', 'note' => 'temporary'], + $patched->getData()->getPayload() + ); + + // A test operation that does not hold has to take the whole document down with it. + $wasRejected = false; + + try { + $this->pubnub->dataSync()->updateEntity() + ->entityId($entityId) + ->patch( + (new PNDataSyncPatch()) + ->test('/payload/make', 'Honda') + ->replace('/status', 'never-applied') + ) + ->sync(); + } catch (PubNubServerException $exception) { + $wasRejected = true; + } + + $this->assertTrue($wasRejected, 'a test operation that does not hold must reject the patch'); + + $verified = $this->pubnub->dataSync()->getEntity()->entityId($entityId)->sync(); + $this->assertSame('patched', $verified->getData()->getStatus(), 'the rejected patch changed nothing'); + } finally { + $this->pubnub->dataSync()->deleteEntity()->entityId($entityId)->sync(); + } + } + + /** + * A delete takes an ETag like the other writes do, which is the only way to be sure the record + * being removed is the one that was read. + */ + public function testDeleteHonoursIfMatch(): void + { + $entityId = $this->createEntity(); + $stale = $this->pubnub->dataSync()->getEntity()->entityId($entityId)->sync(); + + try { + $current = $this->pubnub->dataSync()->setEntity() + ->entityId($entityId) + ->entityClassVersion(1) + ->status('moved-on') + ->payload(['make' => 'Honda']) + ->sync(); + + $staleWasRejected = false; + + try { + $this->pubnub->dataSync()->deleteEntity() + ->entityId($entityId) + ->ifMatchesETag((string) $stale->getETag()) + ->sync(); + } catch (PubNubServerException $exception) { + $staleWasRejected = true; + $this->assertSame(412, $exception->getStatusCode()); + } + + $this->assertTrue($staleWasRejected, 'a delete carrying a stale ETag must fail with 412'); + + $deleted = $this->pubnub->dataSync()->deleteEntity() + ->entityId($entityId) + ->ifMatchesETag((string) $current->getETag()) + ->sync(); + + $this->assertTrue($deleted->isSuccess()); + } finally { + try { + $this->pubnub->dataSync()->deleteEntity()->entityId($entityId)->sync(); + } catch (PubNubServerException $exception) { + // Already gone, which is the expected outcome. + } + } + } + + public function testMissingRecordsAreReportedAsNotFound(): void + { + $missingId = 'php-sdk-missing-' . uniqid(); + + try { + $this->pubnub->dataSync()->getEntity()->entityId($missingId)->sync(); + $this->fail('reading a record that does not exist must fail'); + } catch (PubNubServerException $exception) { + $this->assertSame(404, $exception->getStatusCode()); + } + + try { + $this->pubnub->dataSync()->deleteEntity()->entityId($missingId)->sync(); + $this->fail('deleting a record that does not exist must fail'); + } catch (PubNubServerException $exception) { + $this->assertSame(404, $exception->getStatusCode()); + } + } + + public function testCreateWithoutAnIdGetsOneFromTheServer(): void + { + $created = $this->pubnub->dataSync()->createEntity() + ->entityClass($this->entityClass) + ->entityClassVersion(1) + ->status('active') + ->payload(['make' => 'Toyota']) + ->sync(); + + $entityId = (string) $created->getId(); + + try { + $this->assertNotSame('', $entityId, 'the server assigns an id when none is supplied'); + + $fetched = $this->pubnub->dataSync()->getEntity()->entityId($entityId)->sync(); + $this->assertSame($entityId, $fetched->getId()); + } finally { + $this->pubnub->dataSync()->deleteEntity()->entityId($entityId)->sync(); + } + } + + /** + * Listing with a filter, a sort and a page size, which is the combination the signed request + * path is most exposed to: every one of those values needs characters escaped, and the PAM + * signature is computed over that same escaping. + */ + public function testEntityListingFiltersSortsAndPages(): void + { + // A status nothing else on the shared keyset uses, so the filter selects exactly these. + $status = 'php-sdk-filter-' . uniqid(); + $created = []; + + for ($index = 0; $index < 3; $index++) { + $created[] = $this->createEntity($status); + } + + try { + $first = $this->pubnub->dataSync()->getEntities() + ->entityClass($this->entityClass) + ->filterFast("status == '$status'") + ->sort(['createdAt' => 'desc']) + ->limit(2) + ->sync(); + + $this->assertCount(2, $first->getData()); + $this->assertNotNull($first->getPage()); + $this->assertTrue($first->getPage()->hasNext(), 'three records over a page of two must have a next page'); + $this->assertNotEmpty($first->getPage()->getNextCursor()); + + $second = $this->pubnub->dataSync()->getEntities() + ->entityClass($this->entityClass) + ->filterFast("status == '$status'") + ->sort(['createdAt' => 'desc']) + ->limit(2) + ->cursor((string) $first->getPage()->getNextCursor()) + ->sync(); + + $this->assertCount(1, $second->getData()); + + $listed = array_merge($this->idsOf($first->getData()), $this->idsOf($second->getData())); + + sort($created); + sort($listed); + + $this->assertSame($created, $listed, 'the two pages together are exactly the filtered set'); + } finally { + foreach ($created as $entityId) { + $this->pubnub->dataSync()->deleteEntity()->entityId($entityId)->sync(); + } + } + } + + /** + * @param object[] $records + * @return string[] + */ + private function idsOf(array $records): array + { + return array_map(static fn($record) => (string) $record->getId(), $records); + } + /** * Same round trip for a relationship, which additionally proves that the two linked entities * and the relationship class survive a full replacement while status and payload do not. @@ -236,9 +443,9 @@ public function testRelationshipLifecycle(): void } /** - * Creates a throwaway entity to hang a relationship off and returns its id. + * Creates a throwaway entity to hang a relationship off, or to list, and returns its id. */ - private function createEntity(): string + private function createEntity(string $status = 'active'): string { $entityId = 'php-sdk-lifecycle-' . uniqid(); @@ -246,7 +453,7 @@ private function createEntity(): string ->entityId($entityId) ->entityClass($this->entityClass) ->entityClassVersion(1) - ->status('active') + ->status($status) ->payload(['name' => 'entity-' . $entityId]) ->sync(); diff --git a/tests/unit/dataSync/DataSyncErrorTest.php b/tests/unit/dataSync/DataSyncErrorTest.php new file mode 100644 index 00000000..51830ee8 --- /dev/null +++ b/tests/unit/dataSync/DataSyncErrorTest.php @@ -0,0 +1,61 @@ +setStatusCode($statusCode)->setRawBody($body); + } + + public function testTheFirstErrorInTheListIsSurfaced(): void + { + $exception = $this->exception( + '{"errors":[{"errorCode":"DS-0008","message":"Field at \'/payload/dateBought\' with value ' + . 'x is not of expected type \'date\'","path":"/payload/dateBought"}]}' + ); + + $this->assertStringContainsString('not of expected type', (string) $exception->getServerErrorMessage()); + $this->assertSame(400, $exception->getStatusCode()); + } + + /** + * The code and the path are what a caller acts on, and both stay reachable on the body. + */ + public function testTheErrorCodeAndPathRemainAvailable(): void + { + $body = $this->exception('{"errors":[{"errorCode":"DS-0004","message":"bad","path":"/status"}]}')->getBody(); + + $this->assertSame('DS-0004', $body->errors[0]->errorCode); + $this->assertSame('/status', $body->errors[0]->path); + } + + /** + * An Access Manager denial keeps the shape the older services use, and must still read the + * same way it always has. + */ + public function testAnAccessManagerDenialStillReadsThroughTheOldShape(): void + { + $exception = $this->exception( + '{"error":true,"status":403,"service":"Access Manager","message":"Forbidden"}', + 403 + ); + + $this->assertSame('Forbidden', $exception->getServerErrorMessage()); + } + + public function testAnUnrecognisedBodyReportsNoMessage(): void + { + $this->assertNull($this->exception('{"unexpected":"shape"}')->getServerErrorMessage()); + } +} From a0d1f7b08aa489f305a921267abee2314e86559f Mon Sep 17 00:00:00 2001 From: "PUBNUB\\jakub.grzesiowski" Date: Tue, 22 Sep 2026 17:12:23 +0200 Subject: [PATCH 5/8] Minor composer.json adjustments --- composer.json | 7 +- tests/unit/dataSync/DataSyncAutoloadTest.php | 93 ++++++++++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 tests/unit/dataSync/DataSyncAutoloadTest.php diff --git a/composer.json b/composer.json index 32be381c..e67dbfc5 100644 --- a/composer.json +++ b/composer.json @@ -1,9 +1,10 @@ { "name": "pubnub/pubnub", "type": "library", - "description": "This is the official PubNub PHP SDK repository.", - "keywords": ["api", "real-time", "realtime", "real time", "ajax", "push"], - "homepage": "http://www.pubnub.com/", + "description": "PubNub SDK for PHP. Publish/subscribe messaging over HTTP long-poll with server-assigned message ordering, channel occupancy via Here Now, and message history through Message Persistence.", + "keywords": ["pubnub", "realtime", "real-time", "publish-subscribe", "pubsub", "messaging", "occupancy", +"presence", "message-ordering", "message-history", "chat", "long-poll"], + "homepage": "https://www.pubnub.com", "license": "proprietary", "version": "9.0.3", "authors": [ diff --git a/tests/unit/dataSync/DataSyncAutoloadTest.php b/tests/unit/dataSync/DataSyncAutoloadTest.php new file mode 100644 index 00000000..0256d20c --- /dev/null +++ b/tests/unit/dataSync/DataSyncAutoloadTest.php @@ -0,0 +1,93 @@ +dataSyncFiles(); + + $this->assertGreaterThan(50, count($files), 'the DataSync sources should all have been found'); + + foreach ($files as $relativePath) { + $className = str_replace('/', '\\', substr($relativePath, 0, -strlen('.php'))); + + $this->assertTrue( + class_exists($className) || trait_exists($className) || interface_exists($className), + $relativePath . ' does not autoload as ' . $className + ); + } + } + + /** + * The bundled autoloader is PEAR-flavoured and turns an underscore in a class name into a + * directory separator, so a DataSync class carrying one would be looked for in a directory + * that does not exist. + */ + public function testNoDataSyncClassNameCarriesAnUnderscore(): void + { + foreach ($this->dataSyncFiles() as $relativePath) { + $this->assertStringNotContainsString( + '_', + basename($relativePath), + $relativePath . ' would be misresolved by src/autoloader.php' + ); + } + } + + /** + * @return string[] Paths relative to src/, using forward slashes. + */ + private function dataSyncFiles(): array + { + $root = dirname(__DIR__, 3) . '/src/'; + $files = []; + + foreach (self::SOURCE_PATHS as $path) { + $absolute = $root . $path; + + if (is_file($absolute)) { + $files[] = $path; + continue; + } + + $this->assertDirectoryExists($absolute); + + /** @var SplFileInfo $file */ + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($absolute)) as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + $files[] = $path . '/' . str_replace( + '\\', + '/', + substr($file->getPathname(), strlen($absolute) + 1) + ); + } + } + } + + return $files; + } +} From 8db3d7c94170eb99f6c42dd52e3ea6b68b6883cd Mon Sep 17 00:00:00 2001 From: "PUBNUB\\jakub.grzesiowski" Date: Tue, 22 Sep 2026 17:28:20 +0200 Subject: [PATCH 6/8] Integrate DS tests into CI/CD, fix pubnub.yml feature naming --- .github/workflows/run-tests.yml | 9 +++++ .pubnub.yml | 65 +++++++++++++++++---------------- 2 files changed, 43 insertions(+), 31 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 31e9cc2a..de1b0eab 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -28,6 +28,15 @@ jobs: SUBSCRIBE_PAM_KEY: ${{ secrets.SDK_PAM_SUB_KEY }} SECRET_PAM_KEY: ${{ secrets.SDK_PAM_SEC_KEY }} UUID_MOCK: "test-user" + # DataSync runs against its own keyset. Without these the DataSync tests skip themselves + # rather than fail, which is what happens on pull requests from forks. + DATASYNC_PUBLISH_KEY: ${{ secrets.SDK_DS_PUB_KEY }} + DATASYNC_SUBSCRIBE_KEY: ${{ secrets.SDK_DS_SUB_KEY }} + DATASYNC_SECRET_KEY: ${{ secrets.SDK_DS_SEC_KEY }} + # Classes registered on that keyset, shared with the other SDKs' test suites. Not secret. + DATASYNC_ENTITY_CLASS: "integration-test-vehicle" + DATASYNC_ENTITY_CLASS_PROJECTIONS: "integration-test-vehicle-v2" + DATASYNC_ENTITY_RELATIONSHIP: "integration-test-ownership-v3" steps: - name: Checkout project uses: actions/checkout@v4 diff --git a/.pubnub.yml b/.pubnub.yml index a828cb4c..c77ad9c2 100644 --- a/.pubnub.yml +++ b/.pubnub.yml @@ -315,37 +315,40 @@ features: - ACCESS-PARSE-TOKEN - ACCESS-SET-TOKEN data-sync: - - DATA-SYNC-CREATE-ENTITY - - DATA-SYNC-GET-ENTITY - - DATA-SYNC-GET-ENTITIES - - DATA-SYNC-SET-ENTITY - - DATA-SYNC-UPDATE-ENTITY - - DATA-SYNC-DELETE-ENTITY - - DATA-SYNC-CREATE-RELATIONSHIP - - DATA-SYNC-GET-RELATIONSHIP - - DATA-SYNC-GET-RELATIONSHIPS - - DATA-SYNC-SET-RELATIONSHIP - - DATA-SYNC-UPDATE-RELATIONSHIP - - DATA-SYNC-DELETE-RELATIONSHIP - - DATA-SYNC-CREATE-USER - - DATA-SYNC-GET-USER - - DATA-SYNC-GET-USERS - - DATA-SYNC-SET-USER - - DATA-SYNC-UPDATE-USER - - DATA-SYNC-DELETE-USER - - DATA-SYNC-CREATE-CHANNEL - - DATA-SYNC-GET-CHANNEL - - DATA-SYNC-GET-CHANNELS - - DATA-SYNC-SET-CHANNEL - - DATA-SYNC-UPDATE-CHANNEL - - DATA-SYNC-DELETE-CHANNEL - - DATA-SYNC-CREATE-MEMBERSHIP - - DATA-SYNC-GET-MEMBERSHIP - - DATA-SYNC-GET-MEMBERSHIPS - - DATA-SYNC-SET-MEMBERSHIP - - DATA-SYNC-UPDATE-MEMBERSHIP - - DATA-SYNC-DELETE-MEMBERSHIP - - DATA-SYNC-EVENTS + - DATASYNC-USER-CREATE + - DATASYNC-USER-GET + - DATASYNC-USER-GET-ALL + - DATASYNC-USER-UPDATE + - DATASYNC-USER-PATCH + - DATASYNC-USER-DELETE + - DATASYNC-CHANNEL-CREATE + - DATASYNC-CHANNEL-GET + - DATASYNC-CHANNEL-GET-ALL + - DATASYNC-CHANNEL-UPDATE + - DATASYNC-CHANNEL-PATCH + - DATASYNC-CHANNEL-DELETE + - DATASYNC-MEMBERSHIP-CREATE + - DATASYNC-MEMBERSHIP-GET + - DATASYNC-MEMBERSHIP-GET-ALL + - DATASYNC-MEMBERSHIP-UPDATE + - DATASYNC-MEMBERSHIP-PATCH + - DATASYNC-MEMBERSHIP-DELETE + - DATASYNC-ENTITY-CREATE + - DATASYNC-ENTITY-GET + - DATASYNC-ENTITY-GET-ALL + - DATASYNC-ENTITY-UPDATE + - DATASYNC-ENTITY-PATCH + - DATASYNC-ENTITY-DELETE + - DATASYNC-RELATIONSHIP-CREATE + - DATASYNC-RELATIONSHIP-GET + - DATASYNC-RELATIONSHIP-GET-ALL + - DATASYNC-RELATIONSHIP-UPDATE + - DATASYNC-RELATIONSHIP-PATCH + - DATASYNC-RELATIONSHIP-DELETE + - DATASYNC-FILTERING + - DATASYNC-SORTING + - DATASYNC-EVENTS-LISTENER + - DATASYNC-ACCESS-MANAGER channel-groups: - CHANNEL-GROUPS-ADD-CHANNELS - CHANNEL-GROUPS-REMOVE-CHANNELS From 3a362da684cfcd7c8e405d713f80469b5b0a8649 Mon Sep 17 00:00:00 2001 From: "PUBNUB\\jakub.grzesiowski" Date: Tue, 22 Sep 2026 19:17:12 +0200 Subject: [PATCH 7/8] Additional test logic to reduce flakiness --- tests/helpers/RetriesDataSyncReads.php | 88 ++++++++++++ .../dataSync/DataSyncEventTest.php | 22 +++ .../dataSync/DataSyncGrantTest.php | 11 ++ .../dataSync/DataSyncLifecycleTest.php | 53 ++++++-- .../DataSyncPredefinedLifecycleTest.php | 128 ++++++++++++------ .../dataSync/DataSyncProjectionTest.php | 11 ++ 6 files changed, 261 insertions(+), 52 deletions(-) create mode 100644 tests/helpers/RetriesDataSyncReads.php diff --git a/tests/helpers/RetriesDataSyncReads.php b/tests/helpers/RetriesDataSyncReads.php new file mode 100644 index 00000000..ac0874bc --- /dev/null +++ b/tests/helpers/RetriesDataSyncReads.php @@ -0,0 +1,88 @@ +readRetryBudgetSeconds; + $missing = null; + + while (true) { + try { + $result = $read(); + + if ($accepts === null || $accepts($result)) { + return $result; + } + + $missing = null; + } catch (PubNubServerException $exception) { + if ($exception->getStatusCode() !== 404) { + throw $exception; + } + + $missing = $exception; + } + + if (microtime(true) >= $deadline) { + if ($missing !== null) { + throw $missing; + } + + throw new RuntimeException(sprintf( + '%s did not settle within %d seconds', + $description, + $this->readRetryBudgetSeconds + )); + } + + usleep($this->readRetryIntervalMicroseconds); + } + } + + /** + * Blocks until a record just written can be read back, so that whatever the test does next - + * often writing something that links to it - does not run ahead of it. + * + * @param callable(): mixed $read + */ + protected function readableNow(callable $read, string $description = 'the record'): void + { + $this->readEventually($read, null, $description); + } +} diff --git a/tests/integrational/dataSync/DataSyncEventTest.php b/tests/integrational/dataSync/DataSyncEventTest.php index 1934ef9f..d857c265 100644 --- a/tests/integrational/dataSync/DataSyncEventTest.php +++ b/tests/integrational/dataSync/DataSyncEventTest.php @@ -8,6 +8,7 @@ use PubNub\PNConfiguration; use PubNub\PubNub; use PubNubTests\helpers\DataSyncEventCollector; +use PubNubTests\helpers\RetriesDataSyncReads; /** * DataSync change notifications delivered over a live subscribe connection. @@ -23,6 +24,8 @@ */ class DataSyncEventTest extends TestCase { + use RetriesDataSyncReads; + private PubNub $pubnub; private string $entityClass; @@ -49,6 +52,8 @@ public function setUp(): void $config->setSubscribeKey($subscribeKey); $config->setPublishKey($publishKey); $config->setUuid('datasync-event-test-' . uniqid()); + // Ten seconds is the default and a loaded CI runner occasionally needs more than that. + $config->setNonSubscribeRequestTimeout(30); $secretKey = getenv('DATASYNC_SECRET_KEY') ?: ''; @@ -371,6 +376,13 @@ private function createEntity(): string ->payload(['name' => 'entity-' . $entityId]) ->sync(); + // A relationship is written over this entity from inside a subscribe callback, where a + // failure would surface as a missing event rather than as an error. + $this->readableNow( + fn() => $this->pubnub->dataSync()->getEntity()->entityId($entityId)->sync(), + 'the entity fixture' + ); + return $entityId; } @@ -385,6 +397,11 @@ private function createUser(): string ->payload(['name' => 'user-' . $userId]) ->sync(); + $this->readableNow( + fn() => $this->pubnub->dataSync()->getUser()->userId($userId)->sync(), + 'the user fixture' + ); + return $userId; } @@ -399,6 +416,11 @@ private function createChannel(): string ->payload(['name' => 'channel-' . $channelId]) ->sync(); + $this->readableNow( + fn() => $this->pubnub->dataSync()->getChannel()->channelId($channelId)->sync(), + 'the channel fixture' + ); + return $channelId; } } diff --git a/tests/integrational/dataSync/DataSyncGrantTest.php b/tests/integrational/dataSync/DataSyncGrantTest.php index 7b886cec..b9154c62 100644 --- a/tests/integrational/dataSync/DataSyncGrantTest.php +++ b/tests/integrational/dataSync/DataSyncGrantTest.php @@ -8,6 +8,7 @@ use PubNub\Models\Access\Permissions; use PubNub\PNConfiguration; use PubNub\PubNub; +use PubNubTests\helpers\RetriesDataSyncReads; /** * DataSync grants issued by a real Access Manager and spent against the real data plane. @@ -23,6 +24,8 @@ */ class DataSyncGrantTest extends TestCase { + use RetriesDataSyncReads; + /** The uuid every token here is issued to, and the only one allowed to spend it. */ private const CLIENT_UUID = 'php-sdk-pam-client'; @@ -85,6 +88,8 @@ private function configuration(string $uuid): PNConfiguration $config->setSubscribeKey($this->subscribeKey); $config->setPublishKey($this->publishKey); $config->setUuid($uuid); + // Ten seconds is the default and a loaded CI runner occasionally needs more than that. + $config->setNonSubscribeRequestTimeout(30); if ($origin = getenv('DATASYNC_ORIGIN')) { $config->setOrigin($origin); @@ -137,6 +142,12 @@ private function createEntity(?string $entityId = null): string $this->createdEntityIds[] = $entityId; + // A token is granted over this entity next, and the read it authorises has to find it. + $this->readableNow( + fn() => $this->admin->dataSync()->getEntity()->entityId($entityId)->sync(), + 'the entity fixture' + ); + return $entityId; } diff --git a/tests/integrational/dataSync/DataSyncLifecycleTest.php b/tests/integrational/dataSync/DataSyncLifecycleTest.php index 65495ed1..8902aa08 100644 --- a/tests/integrational/dataSync/DataSyncLifecycleTest.php +++ b/tests/integrational/dataSync/DataSyncLifecycleTest.php @@ -7,6 +7,7 @@ use PubNub\Models\Consumer\DataSync\PNDataSyncPatch; use PubNub\PNConfiguration; use PubNub\PubNub; +use PubNubTests\helpers\RetriesDataSyncReads; /** * Full create-read-update-delete round trip against a real keyset. @@ -21,6 +22,8 @@ */ class DataSyncLifecycleTest extends TestCase { + use RetriesDataSyncReads; + private PubNub $pubnub; private string $entityClass; @@ -47,6 +50,8 @@ public function setUp(): void $config->setSubscribeKey($subscribeKey); $config->setPublishKey($publishKey); $config->setUuid('datasync-lifecycle-test'); + // Ten seconds is the default and a loaded CI runner occasionally needs more than that. + $config->setNonSubscribeRequestTimeout(30); $secretKey = getenv('DATASYNC_SECRET_KEY') ?: ''; @@ -77,7 +82,11 @@ public function testEntityLifecycle(): void $this->assertNotEmpty($created->getETag()); try { - $fetched = $this->pubnub->dataSync()->getEntity()->entityId($entityId)->sync(); + $fetched = $this->readEventually( + fn() => $this->pubnub->dataSync()->getEntity()->entityId($entityId)->sync(), + null, + 'the new entity' + ); $this->assertSame($entityId, $fetched->getId()); $this->assertSame('active', $fetched->getData()->getStatus()); @@ -271,7 +280,11 @@ public function testCreateWithoutAnIdGetsOneFromTheServer(): void try { $this->assertNotSame('', $entityId, 'the server assigns an id when none is supplied'); - $fetched = $this->pubnub->dataSync()->getEntity()->entityId($entityId)->sync(); + $fetched = $this->readEventually( + fn() => $this->pubnub->dataSync()->getEntity()->entityId($entityId)->sync(), + null, + 'the server-named entity' + ); $this->assertSame($entityId, $fetched->getId()); } finally { $this->pubnub->dataSync()->deleteEntity()->entityId($entityId)->sync(); @@ -294,12 +307,20 @@ public function testEntityListingFiltersSortsAndPages(): void } try { - $first = $this->pubnub->dataSync()->getEntities() - ->entityClass($this->entityClass) - ->filterFast("status == '$status'") - ->sort(['createdAt' => 'desc']) - ->limit(2) - ->sync(); + // The listing index sees a new record a moment after a direct read of it works, so the + // page is fetched until all three are in it rather than once and hopefully. + $first = $this->readEventually( + fn() => $this->pubnub->dataSync()->getEntities() + ->entityClass($this->entityClass) + ->filterFast("status == '$status'") + ->sort(['createdAt' => 'desc']) + ->limit(2) + ->sync(), + fn($result) => count($result->getData()) === 2 + && $result->getPage() !== null + && $result->getPage()->hasNext(), + 'the filtered listing' + ); $this->assertCount(2, $first->getData()); $this->assertNotNull($first->getPage()); @@ -370,9 +391,13 @@ public function testRelationshipLifecycle(): void $this->assertNotEmpty($created->getETag()); try { - $fetched = $this->pubnub->dataSync()->getRelationship() - ->relationshipId($relationshipId) - ->sync(); + $fetched = $this->readEventually( + fn() => $this->pubnub->dataSync()->getRelationship() + ->relationshipId($relationshipId) + ->sync(), + null, + 'the new relationship' + ); $this->assertSame($relationshipId, $fetched->getId()); $this->assertSame('new', $fetched->getData()->getStatus()); @@ -457,6 +482,12 @@ private function createEntity(string $status = 'active'): string ->payload(['name' => 'entity-' . $entityId]) ->sync(); + // Relationships written next link to this entity, so it has to be there first. + $this->readableNow( + fn() => $this->pubnub->dataSync()->getEntity()->entityId($entityId)->sync(), + 'the entity fixture' + ); + return $entityId; } } diff --git a/tests/integrational/dataSync/DataSyncPredefinedLifecycleTest.php b/tests/integrational/dataSync/DataSyncPredefinedLifecycleTest.php index 40e290fa..14f682c0 100644 --- a/tests/integrational/dataSync/DataSyncPredefinedLifecycleTest.php +++ b/tests/integrational/dataSync/DataSyncPredefinedLifecycleTest.php @@ -6,6 +6,7 @@ use PubNub\Models\Consumer\DataSync\PNDataSyncPatch; use PubNub\PNConfiguration; use PubNub\PubNub; +use PubNubTests\helpers\RetriesDataSyncReads; /** * Full create-read-update-delete round trip for the three predefined families, against a real @@ -21,6 +22,8 @@ */ class DataSyncPredefinedLifecycleTest extends TestCase { + use RetriesDataSyncReads; + private PubNub $pubnub; public function setUp(): void @@ -41,6 +44,8 @@ public function setUp(): void $config->setSubscribeKey($subscribeKey); $config->setPublishKey($publishKey); $config->setUuid('datasync-predefined-lifecycle-test'); + // Ten seconds is the default and a loaded CI runner occasionally needs more than that. + $config->setNonSubscribeRequestTimeout(30); $secretKey = getenv('DATASYNC_SECRET_KEY') ?: ''; @@ -70,7 +75,11 @@ public function testUserLifecycle(): void $this->assertNotEmpty($created->getETag()); try { - $fetched = $this->pubnub->dataSync()->getUser()->userId($userId)->sync(); + $fetched = $this->readEventually( + fn() => $this->pubnub->dataSync()->getUser()->userId($userId)->sync(), + null, + 'the new user' + ); $this->assertSame($userId, $fetched->getId()); $this->assertSame('active', $fetched->getData()->getStatus()); @@ -108,7 +117,7 @@ public function testUserLifecycle(): void $this->assertSame('archived', $replaced->getData()->getStatus()); $this->assertSame(['name' => 'Alice Cooper'], $replaced->getData()->getPayload()); - $this->assertContains($userId, $this->listedUserIds()); + $this->assertUserIsListed($userId); } finally { $deleted = $this->pubnub->dataSync()->deleteUser()->userId($userId)->sync(); $this->assertTrue($deleted->isSuccess()); @@ -130,7 +139,11 @@ public function testChannelLifecycle(): void $this->assertNotEmpty($created->getETag()); try { - $fetched = $this->pubnub->dataSync()->getChannel()->channelId($channelId)->sync(); + $fetched = $this->readEventually( + fn() => $this->pubnub->dataSync()->getChannel()->channelId($channelId)->sync(), + null, + 'the new channel' + ); $this->assertSame($channelId, $fetched->getId()); $this->assertSame('active', $fetched->getData()->getStatus()); @@ -164,7 +177,7 @@ public function testChannelLifecycle(): void $this->assertSame('archived', $replaced->getData()->getStatus()); $this->assertSame(['name' => 'Support (retired)'], $replaced->getData()->getPayload()); - $this->assertContains($channelId, $this->listedChannelIds()); + $this->assertChannelIsListed($channelId); } finally { $deleted = $this->pubnub->dataSync()->deleteChannel()->channelId($channelId)->sync(); $this->assertTrue($deleted->isSuccess()); @@ -196,9 +209,13 @@ public function testMembershipLifecycle(): void $this->assertNotEmpty($created->getETag()); try { - $fetched = $this->pubnub->dataSync()->getMembership() - ->membershipId($membershipId) - ->sync(); + $fetched = $this->readEventually( + fn() => $this->pubnub->dataSync()->getMembership() + ->membershipId($membershipId) + ->sync(), + null, + 'the new membership' + ); $this->assertSame($membershipId, $fetched->getId()); $this->assertSame($channelId, $fetched->getData()->getChannelId()); @@ -236,14 +253,14 @@ public function testMembershipLifecycle(): void $this->assertSame('patched', $patched->getData()->getStatus()); $this->assertEquals(['role' => 'admin', 'note' => 'promoted'], $patched->getData()->getPayload()); - $this->assertContains( + $this->assertMembershipIsListed( $membershipId, - $this->listedMembershipIds(['userId' => $userId]), + ['userId' => $userId], 'the membership should be listed among the channels the user belongs to' ); - $this->assertContains( + $this->assertMembershipIsListed( $membershipId, - $this->listedMembershipIds(['channelId' => $channelId]), + ['channelId' => $channelId], 'and among the members of the channel' ); } finally { @@ -260,54 +277,72 @@ public function testMembershipLifecycle(): void /** * Newest first, so the record written moments ago is on the first page however many the keyset - * has accumulated. - * - * @return string[] + * has accumulated. Retried as well, because the listing index picks a record up a moment after + * a direct read of it already works. */ - private function listedUserIds(): array + private function assertUserIsListed(string $userId): void { - $listed = $this->pubnub->dataSync()->getUsers() - ->limit(100) - ->sort(['createdAt' => 'desc']) - ->sync(); + $listed = $this->readEventually( + fn() => $this->pubnub->dataSync()->getUsers() + ->limit(100) + ->sort(['createdAt' => 'desc']) + ->sync(), + fn($result) => in_array($userId, $this->idsOf($result->getData()), true), + 'the user listing' + ); $this->assertNotNull($listed->getPage()); - - return array_map(static fn($user) => $user->getId(), $listed->getData()); + $this->assertContains($userId, $this->idsOf($listed->getData())); } - /** - * @return string[] - */ - private function listedChannelIds(): array + private function assertChannelIsListed(string $channelId): void { - $listed = $this->pubnub->dataSync()->getChannels() - ->limit(100) - ->sort(['createdAt' => 'desc']) - ->sync(); + $listed = $this->readEventually( + fn() => $this->pubnub->dataSync()->getChannels() + ->limit(100) + ->sort(['createdAt' => 'desc']) + ->sync(), + fn($result) => in_array($channelId, $this->idsOf($result->getData()), true), + 'the channel listing' + ); $this->assertNotNull($listed->getPage()); - - return array_map(static fn($channel) => $channel->getId(), $listed->getData()); + $this->assertContains($channelId, $this->idsOf($listed->getData())); } /** * @param array{userId?: string, channelId?: string} $side - * @return string[] */ - private function listedMembershipIds(array $side): array + private function assertMembershipIsListed(string $membershipId, array $side, string $message): void { - $endpoint = $this->pubnub->dataSync()->getMemberships()->limit(100); + $listed = $this->readEventually( + function () use ($side) { + $endpoint = $this->pubnub->dataSync()->getMemberships()->limit(100); - if (isset($side['userId'])) { - $endpoint->userId($side['userId']); - } + if (isset($side['userId'])) { + $endpoint->userId($side['userId']); + } - if (isset($side['channelId'])) { - $endpoint->channelId($side['channelId']); - } + if (isset($side['channelId'])) { + $endpoint->channelId($side['channelId']); + } - return array_map(static fn($membership) => $membership->getId(), $endpoint->sync()->getData()); + return $endpoint->sync(); + }, + fn($result) => in_array($membershipId, $this->idsOf($result->getData()), true), + 'the membership listing' + ); + + $this->assertContains($membershipId, $this->idsOf($listed->getData()), $message); + } + + /** + * @param object[] $records + * @return string[] + */ + private function idsOf(array $records): array + { + return array_map(static fn($record) => (string) $record->getId(), $records); } private function createUser(): string @@ -321,6 +356,12 @@ private function createUser(): string ->payload(['name' => 'user-' . $userId]) ->sync(); + // The membership written next links to this user, so it has to be there first. + $this->readableNow( + fn() => $this->pubnub->dataSync()->getUser()->userId($userId)->sync(), + 'the user fixture' + ); + return $userId; } @@ -335,6 +376,11 @@ private function createChannel(): string ->payload(['name' => 'channel-' . $channelId]) ->sync(); + $this->readableNow( + fn() => $this->pubnub->dataSync()->getChannel()->channelId($channelId)->sync(), + 'the channel fixture' + ); + return $channelId; } } diff --git a/tests/integrational/dataSync/DataSyncProjectionTest.php b/tests/integrational/dataSync/DataSyncProjectionTest.php index 575cad6f..648e49d7 100644 --- a/tests/integrational/dataSync/DataSyncProjectionTest.php +++ b/tests/integrational/dataSync/DataSyncProjectionTest.php @@ -8,6 +8,7 @@ use PubNub\Models\Consumer\DataSync\PNDataSyncPatch; use PubNub\PNConfiguration; use PubNub\PubNub; +use PubNubTests\helpers\RetriesDataSyncReads; /** * What a DataSync projection actually hides, proven against the live data plane. @@ -22,6 +23,8 @@ */ class DataSyncProjectionTest extends TestCase { + use RetriesDataSyncReads; + private const CLIENT_UUID = 'php-sdk-projection-client'; private const ADMIN_PROJECTION = 'admin'; @@ -91,6 +94,8 @@ private function configuration(string $uuid): PNConfiguration $config->setSubscribeKey($this->subscribeKey); $config->setPublishKey($this->publishKey); $config->setUuid($uuid); + // Ten seconds is the default and a loaded CI runner occasionally needs more than that. + $config->setNonSubscribeRequestTimeout(30); if ($origin = getenv('DATASYNC_ORIGIN')) { $config->setOrigin($origin); @@ -117,6 +122,12 @@ private function createVehicle(?string $entityId = null): string $this->createdEntityIds[] = $entityId; + // A projection-scoped token is granted over this entity next. + $this->readableNow( + fn() => $this->admin->dataSync()->getEntity()->entityId($entityId)->sync(), + 'the vehicle fixture' + ); + return $entityId; } From cc15a177086305fd2a37029994b1f1995ae31325 Mon Sep 17 00:00:00 2001 From: "PUBNUB\\jakub.grzesiowski" Date: Fri, 25 Sep 2026 16:19:04 +0200 Subject: [PATCH 8/8] Implement review feedback --- .../DataSync/Channel/DeleteChannel.php | 3 + .../DataSync/DataSyncCollectionEndpoint.php | 29 +++++++-- .../Endpoints/DataSync/DataSyncEndpoint.php | 3 - .../DataSync/Entity/DeleteEntity.php | 3 + .../DataSync/Membership/DeleteMembership.php | 3 + .../Relationship/DeleteRelationship.php | 3 + .../Endpoints/DataSync/User/DeleteUser.php | 3 + src/PubNub/Managers/SubscriptionManager.php | 20 +++++- .../DataSync/PNDataSyncCollectionResult.php | 65 +++++++++++++++++++ .../DataSync/PNDataSyncEntitiesResult.php | 53 ++------------- .../Consumer/DataSync/PNDataSyncEntity.php | 2 +- .../DataSync/PNDataSyncEntityResult.php | 36 ++-------- .../DataSync/PNDataSyncEventResult.php | 1 + .../DataSync/PNDataSyncMembership.php | 2 +- .../DataSync/PNDataSyncMembershipResult.php | 36 ++-------- .../DataSync/PNDataSyncMembershipsResult.php | 53 ++------------- .../Consumer/DataSync/PNDataSyncRecord.php | 36 ++++++++++ .../DataSync/PNDataSyncRecordResult.php | 54 +++++++++++++++ .../DataSync/PNDataSyncRelationship.php | 2 +- .../DataSync/PNDataSyncRelationshipResult.php | 36 ++-------- .../PNDataSyncRelationshipsResult.php | 53 ++------------- .../PNFetchMessagesItemResult.php | 10 ++- .../dataSync/DataSyncRequestTest.php | 23 +++++++ tests/integrational/FetchMessagesTest.php | 33 ++++++++++ .../dataSync/DataSyncEndpointTest.php | 14 ++++ .../dataSync/DataSyncSubscribeTest.php | 30 +++++++++ .../unit/dataSync/DataSyncEventResultTest.php | 16 +++++ 27 files changed, 370 insertions(+), 252 deletions(-) create mode 100644 src/PubNub/Models/Consumer/DataSync/PNDataSyncCollectionResult.php create mode 100644 src/PubNub/Models/Consumer/DataSync/PNDataSyncRecord.php create mode 100644 src/PubNub/Models/Consumer/DataSync/PNDataSyncRecordResult.php diff --git a/src/PubNub/Endpoints/DataSync/Channel/DeleteChannel.php b/src/PubNub/Endpoints/DataSync/Channel/DeleteChannel.php index 03b3c4e3..16ff7916 100644 --- a/src/PubNub/Endpoints/DataSync/Channel/DeleteChannel.php +++ b/src/PubNub/Endpoints/DataSync/Channel/DeleteChannel.php @@ -19,6 +19,9 @@ class DeleteChannel extends DataSyncEndpoint protected const RESOURCE = "channels"; protected const MEDIA_TYPE = "application/vnd.pubnub.objects.channel+json;version=1"; + /** A successful delete answers 200 with no body; every other operation returns one. */ + protected const RESPONSE_MAY_BE_EMPTY = true; + protected string $endpointHttpMethod = PNHttpMethod::DELETE; protected int $endpointOperationType = PNOperationType::PNDataSyncDeleteChannelOperation; protected string $endpointName = "DeleteChannel"; diff --git a/src/PubNub/Endpoints/DataSync/DataSyncCollectionEndpoint.php b/src/PubNub/Endpoints/DataSync/DataSyncCollectionEndpoint.php index 99a876b5..685d40dd 100644 --- a/src/PubNub/Endpoints/DataSync/DataSyncCollectionEndpoint.php +++ b/src/PubNub/Endpoints/DataSync/DataSyncCollectionEndpoint.php @@ -2,6 +2,8 @@ namespace PubNub\Endpoints\DataSync; +use PubNub\Exceptions\PubNubValidationException; + /** * Base for the paginated DataSync list endpoints. * @@ -72,16 +74,33 @@ public function filter(string $filter): static /** * Accepts either a ready-made string ("name:desc,type") or an array - * (['name' => 'desc', 'type']) that gets joined into one. + * (['name' => 'desc', 'type']) that gets joined into one. A direction may be written in any + * case; an entry given without one sorts ascending. * * Only properties declared with a `filtering` mode other than `none` in the class registry * can be sorted on. * * @param array|string $sort * @return $this + * @throws PubNubValidationException when a direction is neither asc nor desc, which would + * otherwise be quietly dropped and leave the results sorted the other way. */ public function sort($sort): static { + if (is_array($sort)) { + foreach ($sort as $field => $direction) { + if (is_int($field)) { + continue; + } + + if (!in_array(strtolower($direction), ['asc', 'desc'], true)) { + throw new PubNubValidationException( + "sort direction for \"$field\" must be asc or desc, got \"$direction\"" + ); + } + } + } + $this->sort = $sort; return $this; } @@ -136,11 +155,11 @@ private function buildSortValue(): ?string foreach ($this->sort as $key => $value) { if (is_int($key)) { $entries[] = $value; - } elseif ($value === 'asc' || $value === 'desc') { - $entries[] = "$key:$value"; - } else { - $entries[] = $key; + continue; } + + // Whatever case the direction was written in, the server wants it lower. + $entries[] = $key . ':' . strtolower($value); } return join(",", $entries); diff --git a/src/PubNub/Endpoints/DataSync/DataSyncEndpoint.php b/src/PubNub/Endpoints/DataSync/DataSyncEndpoint.php index 321c1502..225686f6 100644 --- a/src/PubNub/Endpoints/DataSync/DataSyncEndpoint.php +++ b/src/PubNub/Endpoints/DataSync/DataSyncEndpoint.php @@ -27,9 +27,6 @@ abstract class DataSyncEndpoint extends Endpoint protected const PATCH_MEDIA_TYPE = "application/json-patch+json"; - /** A delete answers 200 with no body. */ - protected const RESPONSE_MAY_BE_EMPTY = true; - protected bool $endpointAuthRequired = true; /** Identifier of the addressed resource; null for create and list operations. */ diff --git a/src/PubNub/Endpoints/DataSync/Entity/DeleteEntity.php b/src/PubNub/Endpoints/DataSync/Entity/DeleteEntity.php index 029598ea..13d9d04f 100644 --- a/src/PubNub/Endpoints/DataSync/Entity/DeleteEntity.php +++ b/src/PubNub/Endpoints/DataSync/Entity/DeleteEntity.php @@ -19,6 +19,9 @@ class DeleteEntity extends DataSyncEndpoint protected const RESOURCE = "entities"; protected const MEDIA_TYPE = "application/vnd.pubnub.objects.entity+json;version=1"; + /** A successful delete answers 200 with no body; every other operation returns one. */ + protected const RESPONSE_MAY_BE_EMPTY = true; + protected string $endpointHttpMethod = PNHttpMethod::DELETE; protected int $endpointOperationType = PNOperationType::PNDataSyncDeleteEntityOperation; protected string $endpointName = "DeleteEntity"; diff --git a/src/PubNub/Endpoints/DataSync/Membership/DeleteMembership.php b/src/PubNub/Endpoints/DataSync/Membership/DeleteMembership.php index 4d7af1bf..f8898af6 100644 --- a/src/PubNub/Endpoints/DataSync/Membership/DeleteMembership.php +++ b/src/PubNub/Endpoints/DataSync/Membership/DeleteMembership.php @@ -19,6 +19,9 @@ class DeleteMembership extends DataSyncEndpoint protected const RESOURCE = "memberships"; protected const MEDIA_TYPE = "application/vnd.pubnub.objects.membership+json;version=1"; + /** A successful delete answers 200 with no body; every other operation returns one. */ + protected const RESPONSE_MAY_BE_EMPTY = true; + protected string $endpointHttpMethod = PNHttpMethod::DELETE; protected int $endpointOperationType = PNOperationType::PNDataSyncDeleteMembershipOperation; protected string $endpointName = "DeleteMembership"; diff --git a/src/PubNub/Endpoints/DataSync/Relationship/DeleteRelationship.php b/src/PubNub/Endpoints/DataSync/Relationship/DeleteRelationship.php index 90179c6c..6276308d 100644 --- a/src/PubNub/Endpoints/DataSync/Relationship/DeleteRelationship.php +++ b/src/PubNub/Endpoints/DataSync/Relationship/DeleteRelationship.php @@ -19,6 +19,9 @@ class DeleteRelationship extends DataSyncEndpoint protected const RESOURCE = "relationships"; protected const MEDIA_TYPE = "application/vnd.pubnub.objects.relationship+json;version=1"; + /** A successful delete answers 200 with no body; every other operation returns one. */ + protected const RESPONSE_MAY_BE_EMPTY = true; + protected string $endpointHttpMethod = PNHttpMethod::DELETE; protected int $endpointOperationType = PNOperationType::PNDataSyncDeleteRelationshipOperation; protected string $endpointName = "DeleteRelationship"; diff --git a/src/PubNub/Endpoints/DataSync/User/DeleteUser.php b/src/PubNub/Endpoints/DataSync/User/DeleteUser.php index 1e280559..528173fa 100644 --- a/src/PubNub/Endpoints/DataSync/User/DeleteUser.php +++ b/src/PubNub/Endpoints/DataSync/User/DeleteUser.php @@ -19,6 +19,9 @@ class DeleteUser extends DataSyncEndpoint protected const RESOURCE = "users"; protected const MEDIA_TYPE = "application/vnd.pubnub.objects.user+json;version=1"; + /** A successful delete answers 200 with no body; every other operation returns one. */ + protected const RESPONSE_MAY_BE_EMPTY = true; + protected string $endpointHttpMethod = PNHttpMethod::DELETE; protected int $endpointOperationType = PNOperationType::PNDataSyncDeleteUserOperation; protected string $endpointName = "DeleteUser"; diff --git a/src/PubNub/Managers/SubscriptionManager.php b/src/PubNub/Managers/SubscriptionManager.php index ed8314e3..78ec5622 100644 --- a/src/PubNub/Managers/SubscriptionManager.php +++ b/src/PubNub/Managers/SubscriptionManager.php @@ -312,10 +312,24 @@ protected function processIncomingPayload($message) */ protected function processMessage($message) { - if ($this->pubnub->getConfiguration()->getCryptoSafe() === null) { + $crypto = $this->pubnub->getConfiguration()->getCryptoSafe(); + + if ($crypto === null) { return $message; - } else { - return $this->pubnub->getConfiguration()->getCryptoSafe()->decrypt($message); } + + // Ciphertext arrives either as a string or wrapped in pn_other, and the decryptor only + // accepts those two shapes. Anything else was never encrypted - a DataSync event is a + // plain object, for one - and has to be passed through rather than handed over, which + // would raise a TypeError the subscribe loop does not catch. + if (is_string($message) || is_object($message)) { + return $crypto->decrypt($message); + } + + if (is_array($message) && is_string($message['pn_other'] ?? null)) { + $message['pn_other'] = $crypto->decrypt($message['pn_other']); + } + + return $message; } } diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncCollectionResult.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncCollectionResult.php new file mode 100644 index 00000000..8fefe1a0 --- /dev/null +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncCollectionResult.php @@ -0,0 +1,65 @@ +data = $data; + $this->page = $page; + } + + public function getPage(): ?PNDataSyncPage + { + return $this->page; + } + + public function count(): int + { + return count($this->data); + } + + public function __toString(): string + { + return sprintf("count: %s, page: %s", count($this->data), $this->page); + } + + /** + * @param array $item One element of the envelope's data array. + */ + abstract protected static function recordFromPayload(array $item): PNDataSyncRecord; + + /** + * @param array $payload + */ + public static function fromPayload(array $payload): static + { + $items = []; + + foreach (PNDataSyncValue::arrayOrNull($payload, "data") ?? [] as $item) { + if (is_array($item)) { + $items[] = static::recordFromPayload($item); + } + } + + $meta = PNDataSyncValue::arrayOrNull($payload, "meta"); + + return new static($items, $meta === null ? null : PNDataSyncPage::fromPayload($meta)); + } +} diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncEntitiesResult.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncEntitiesResult.php index 17dda29a..bf2c9850 100644 --- a/src/PubNub/Models/Consumer/DataSync/PNDataSyncEntitiesResult.php +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncEntitiesResult.php @@ -5,65 +5,22 @@ /** * Response of the paginated entity list operation. */ -class PNDataSyncEntitiesResult +class PNDataSyncEntitiesResult extends PNDataSyncCollectionResult { - /** @var PNDataSyncEntity[] */ - protected array $data; - - protected ?PNDataSyncPage $page; - - /** - * @param PNDataSyncEntity[] $data - */ - final public function __construct(array $data, ?PNDataSyncPage $page = null) - { - $this->data = $data; - $this->page = $page; - } - /** * @return PNDataSyncEntity[] */ public function getData(): array { + /** @var PNDataSyncEntity[] */ return $this->data; } - public function getPage(): ?PNDataSyncPage - { - return $this->page; - } - - public function count(): int - { - return count($this->data); - } - - public function __toString(): string - { - return sprintf("count: %s, page: %s", count($this->data), $this->page); - } - /** - * @param array $payload + * @param array $item */ - public static function fromPayload(array $payload): static + protected static function recordFromPayload(array $item): PNDataSyncEntity { - $items = []; - - if (array_key_exists("data", $payload) && is_array($payload["data"])) { - foreach ($payload["data"] as $item) { - if (is_array($item)) { - $items[] = PNDataSyncEntity::fromPayload($item); - } - } - } - - $meta = PNDataSyncValue::arrayOrNull($payload, "meta"); - - return new static( - $items, - $meta === null ? null : PNDataSyncPage::fromPayload($meta) - ); + return PNDataSyncEntity::fromPayload($item); } } diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncEntity.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncEntity.php index 712b17e0..6a23d42b 100644 --- a/src/PubNub/Models/Consumer/DataSync/PNDataSyncEntity.php +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncEntity.php @@ -11,7 +11,7 @@ * Timestamps stay as the raw strings returned by the server rather than being parsed into * DateTime objects, matching the other PubNub SDKs. */ -class PNDataSyncEntity +class PNDataSyncEntity implements PNDataSyncRecord { protected ?string $id; diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncEntityResult.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncEntityResult.php index c9511af2..3072a990 100644 --- a/src/PubNub/Models/Consumer/DataSync/PNDataSyncEntityResult.php +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncEntityResult.php @@ -5,45 +5,19 @@ /** * Response of the single-entity DataSync operations (create, get, set and update). */ -class PNDataSyncEntityResult +class PNDataSyncEntityResult extends PNDataSyncRecordResult { - protected PNDataSyncEntity $data; - - final public function __construct(PNDataSyncEntity $data) - { - $this->data = $data; - } - public function getData(): PNDataSyncEntity { + /** @var PNDataSyncEntity */ return $this->data; } - public function getId(): ?string - { - return $this->data->getId(); - } - - /** - * Fingerprint to hand back through ifMatchesETag() on the next write. - */ - public function getETag(): ?string - { - return $this->data->getETag(); - } - - public function __toString(): string - { - return (string) $this->data; - } - /** - * @param array $payload + * @param array $data */ - public static function fromPayload(array $payload): static + protected static function recordFromPayload(array $data): PNDataSyncEntity { - $data = PNDataSyncValue::arrayOrNull($payload, "data") ?? []; - - return new static(PNDataSyncEntity::fromPayload($data)); + return PNDataSyncEntity::fromPayload($data); } } diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncEventResult.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncEventResult.php index 3c18066b..8544cd86 100644 --- a/src/PubNub/Models/Consumer/DataSync/PNDataSyncEventResult.php +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncEventResult.php @@ -281,6 +281,7 @@ public static function fromPayload( $entity = PNDataSyncEntity::fromPayload(array_merge($data, [ 'entityClass' => $className, 'entityClassVersion' => $classVersion, + 'entityClassLevel' => $classLevel, ])); } elseif (in_array($typeName, self::RELATIONSHIP_TYPES, true)) { $record = array_merge($data, [ diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncMembership.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncMembership.php index 0cf48601..b9685694 100644 --- a/src/PubNub/Models/Consumer/DataSync/PNDataSyncMembership.php +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncMembership.php @@ -8,7 +8,7 @@ * Memberships are a predefined relationship class between a Channel and a User. The API exposes them * through channelId and userId rather than the generic entityAId and entityBId of a relationship. */ -class PNDataSyncMembership +class PNDataSyncMembership implements PNDataSyncRecord { protected ?string $id; diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncMembershipResult.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncMembershipResult.php index 1dc42e5d..0447fb2c 100644 --- a/src/PubNub/Models/Consumer/DataSync/PNDataSyncMembershipResult.php +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncMembershipResult.php @@ -5,45 +5,19 @@ /** * Response of the single-membership DataSync operations (create, get, set and update). */ -class PNDataSyncMembershipResult +class PNDataSyncMembershipResult extends PNDataSyncRecordResult { - protected PNDataSyncMembership $data; - - final public function __construct(PNDataSyncMembership $data) - { - $this->data = $data; - } - public function getData(): PNDataSyncMembership { + /** @var PNDataSyncMembership */ return $this->data; } - public function getId(): ?string - { - return $this->data->getId(); - } - - /** - * Fingerprint to hand back through ifMatchesETag() on the next write. - */ - public function getETag(): ?string - { - return $this->data->getETag(); - } - - public function __toString(): string - { - return (string) $this->data; - } - /** - * @param array $payload + * @param array $data */ - public static function fromPayload(array $payload): static + protected static function recordFromPayload(array $data): PNDataSyncMembership { - $data = PNDataSyncValue::arrayOrNull($payload, "data") ?? []; - - return new static(PNDataSyncMembership::fromPayload($data)); + return PNDataSyncMembership::fromPayload($data); } } diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncMembershipsResult.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncMembershipsResult.php index 98b67196..98895313 100644 --- a/src/PubNub/Models/Consumer/DataSync/PNDataSyncMembershipsResult.php +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncMembershipsResult.php @@ -5,65 +5,22 @@ /** * Response of the paginated membership list operation. */ -class PNDataSyncMembershipsResult +class PNDataSyncMembershipsResult extends PNDataSyncCollectionResult { - /** @var PNDataSyncMembership[] */ - protected array $data; - - protected ?PNDataSyncPage $page; - - /** - * @param PNDataSyncMembership[] $data - */ - final public function __construct(array $data, ?PNDataSyncPage $page = null) - { - $this->data = $data; - $this->page = $page; - } - /** * @return PNDataSyncMembership[] */ public function getData(): array { + /** @var PNDataSyncMembership[] */ return $this->data; } - public function getPage(): ?PNDataSyncPage - { - return $this->page; - } - - public function count(): int - { - return count($this->data); - } - - public function __toString(): string - { - return sprintf("count: %s, page: %s", count($this->data), $this->page); - } - /** - * @param array $payload + * @param array $item */ - public static function fromPayload(array $payload): static + protected static function recordFromPayload(array $item): PNDataSyncMembership { - $items = []; - - if (array_key_exists("data", $payload) && is_array($payload["data"])) { - foreach ($payload["data"] as $item) { - if (is_array($item)) { - $items[] = PNDataSyncMembership::fromPayload($item); - } - } - } - - $meta = PNDataSyncValue::arrayOrNull($payload, "meta"); - - return new static( - $items, - $meta === null ? null : PNDataSyncPage::fromPayload($meta) - ); + return PNDataSyncMembership::fromPayload($item); } } diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncRecord.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncRecord.php new file mode 100644 index 00000000..5f9f19fa --- /dev/null +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncRecord.php @@ -0,0 +1,36 @@ +|null + */ + public function getPayload(): ?array; + + public function getCreatedAt(): ?string; + + public function getUpdatedAt(): ?string; + + /** + * Fingerprint to hand back through ifMatchesETag() on the next write. + */ + public function getETag(): ?string; + + public function getExpiresAt(): ?string; + + public function __toString(): string; +} diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncRecordResult.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncRecordResult.php new file mode 100644 index 00000000..51ed00fa --- /dev/null +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncRecordResult.php @@ -0,0 +1,54 @@ +data = $data; + } + + public function getId(): ?string + { + return $this->data->getId(); + } + + /** + * Fingerprint to hand back through ifMatchesETag() on the next write. + */ + public function getETag(): ?string + { + return $this->data->getETag(); + } + + public function __toString(): string + { + return (string) $this->data; + } + + /** + * @param array $data Contents of the envelope's data member. + */ + abstract protected static function recordFromPayload(array $data): PNDataSyncRecord; + + /** + * @param array $payload + */ + public static function fromPayload(array $payload): static + { + return new static( + static::recordFromPayload(PNDataSyncValue::arrayOrNull($payload, "data") ?? []) + ); + } +} diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncRelationship.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncRelationship.php index b3f6f1be..c0f5a155 100644 --- a/src/PubNub/Models/Consumer/DataSync/PNDataSyncRelationship.php +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncRelationship.php @@ -5,7 +5,7 @@ /** * A DataSync relationship record linking two entities. */ -class PNDataSyncRelationship +class PNDataSyncRelationship implements PNDataSyncRecord { protected ?string $id; diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncRelationshipResult.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncRelationshipResult.php index 7d25cfcf..c55c200f 100644 --- a/src/PubNub/Models/Consumer/DataSync/PNDataSyncRelationshipResult.php +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncRelationshipResult.php @@ -5,45 +5,19 @@ /** * Response of the single-relationship DataSync operations (create, get, set and update). */ -class PNDataSyncRelationshipResult +class PNDataSyncRelationshipResult extends PNDataSyncRecordResult { - protected PNDataSyncRelationship $data; - - final public function __construct(PNDataSyncRelationship $data) - { - $this->data = $data; - } - public function getData(): PNDataSyncRelationship { + /** @var PNDataSyncRelationship */ return $this->data; } - public function getId(): ?string - { - return $this->data->getId(); - } - - /** - * Fingerprint to hand back through ifMatchesETag() on the next write. - */ - public function getETag(): ?string - { - return $this->data->getETag(); - } - - public function __toString(): string - { - return (string) $this->data; - } - /** - * @param array $payload + * @param array $data */ - public static function fromPayload(array $payload): static + protected static function recordFromPayload(array $data): PNDataSyncRelationship { - $data = PNDataSyncValue::arrayOrNull($payload, "data") ?? []; - - return new static(PNDataSyncRelationship::fromPayload($data)); + return PNDataSyncRelationship::fromPayload($data); } } diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncRelationshipsResult.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncRelationshipsResult.php index 60651d88..d1d5742a 100644 --- a/src/PubNub/Models/Consumer/DataSync/PNDataSyncRelationshipsResult.php +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncRelationshipsResult.php @@ -5,65 +5,22 @@ /** * Response of the paginated relationship list operation. */ -class PNDataSyncRelationshipsResult +class PNDataSyncRelationshipsResult extends PNDataSyncCollectionResult { - /** @var PNDataSyncRelationship[] */ - protected array $data; - - protected ?PNDataSyncPage $page; - - /** - * @param PNDataSyncRelationship[] $data - */ - final public function __construct(array $data, ?PNDataSyncPage $page = null) - { - $this->data = $data; - $this->page = $page; - } - /** * @return PNDataSyncRelationship[] */ public function getData(): array { + /** @var PNDataSyncRelationship[] */ return $this->data; } - public function getPage(): ?PNDataSyncPage - { - return $this->page; - } - - public function count(): int - { - return count($this->data); - } - - public function __toString(): string - { - return sprintf("count: %s, page: %s", count($this->data), $this->page); - } - /** - * @param array $payload + * @param array $item */ - public static function fromPayload(array $payload): static + protected static function recordFromPayload(array $item): PNDataSyncRelationship { - $items = []; - - if (array_key_exists("data", $payload) && is_array($payload["data"])) { - foreach ($payload["data"] as $item) { - if (is_array($item)) { - $items[] = PNDataSyncRelationship::fromPayload($item); - } - } - } - - $meta = PNDataSyncValue::arrayOrNull($payload, "meta"); - - return new static( - $items, - $meta === null ? null : PNDataSyncPage::fromPayload($meta) - ); + return PNDataSyncRelationship::fromPayload($item); } } diff --git a/src/PubNub/Models/Consumer/MessagePersistence/PNFetchMessagesItemResult.php b/src/PubNub/Models/Consumer/MessagePersistence/PNFetchMessagesItemResult.php index 7ea0b0ec..af83b62b 100644 --- a/src/PubNub/Models/Consumer/MessagePersistence/PNFetchMessagesItemResult.php +++ b/src/PubNub/Models/Consumer/MessagePersistence/PNFetchMessagesItemResult.php @@ -87,8 +87,16 @@ public function getCustomMessageType(): ?string public static function fromJson($json, $crypto): static { $message = $json['message']; + if ($crypto) { - $message = $crypto->decrypt($message); + // Ciphertext is either a string or wrapped in pn_other, and decrypt() only takes a + // string or an object. A message that is neither - anything published as a plain JSON + // object - was never encrypted, and handing it over raises a TypeError. + if (is_string($message) || is_object($message)) { + $message = $crypto->decrypt($message); + } elseif (is_array($message) && is_string($message['pn_other'] ?? null)) { + $message['pn_other'] = $crypto->decrypt($message['pn_other']); + } } $item = new static( $message, diff --git a/tests/functional/dataSync/DataSyncRequestTest.php b/tests/functional/dataSync/DataSyncRequestTest.php index f87fdd04..415d1ea4 100644 --- a/tests/functional/dataSync/DataSyncRequestTest.php +++ b/tests/functional/dataSync/DataSyncRequestTest.php @@ -3,6 +3,7 @@ namespace PubNubTests\functional\dataSync; use PHPUnit\Framework\TestCase; +use PubNub\Exceptions\PubNubValidationException; use PubNub\Models\Consumer\DataSync\PNDataSyncPatch; use PubNub\PNConfiguration; use PubNub\PubNub; @@ -187,6 +188,28 @@ public function testASignedListRequestEncodesItsQueryOnlyOnce(): void $this->assertSame($expected, $this->query($request)['signature']); } + /** + * A direction written in capitals used to fall through to the branch that emits the field on + * its own, which the server reads as ascending - the opposite of what was asked for. + */ + public function testSortDirectionIsAcceptedInAnyCase(): void + { + $request = $this->pubnub->dataSync()->getEntities() + ->entityClass('vehicle') + ->sort(['createdAt' => 'DESC', 'status' => 'Asc', 'model']) + ->getRequest(); + + $this->assertSame('createdAt:desc,status:asc,model', $this->query($request)['sort']); + } + + public function testAnUnknownSortDirectionIsRejected(): void + { + $this->expectException(PubNubValidationException::class); + $this->expectExceptionMessage('sort direction for "createdAt" must be asc or desc'); + + $this->pubnub->dataSync()->getEntities()->sort(['createdAt' => 'descending']); + } + public function testSetEntityRequest(): void { $request = $this->pubnub->dataSync()->setEntity() diff --git a/tests/integrational/FetchMessagesTest.php b/tests/integrational/FetchMessagesTest.php index 0eab7ea0..ff2795ae 100644 --- a/tests/integrational/FetchMessagesTest.php +++ b/tests/integrational/FetchMessagesTest.php @@ -162,6 +162,39 @@ public function testFetchEncrypted() $response->getChannels()[self::ENCRYPTED_CHANNEL_NAME][0]->getMessage() ); } + + /** + * A message published as a plain JSON object was never encrypted, and decrypt() only takes a + * string or an object, so handing the decoded array over raised a TypeError and failed the + * whole fetch for a client that merely has encryption configured. + */ + public function testFetchEncryptedPassesAnUnencryptedObjectThrough(): void + { + $subKey = $this->pubnub_enc->getConfiguration()->getSubscribeKey(); + $fetchMessages = new FetchMessagesExposed($this->pubnub_enc); + + $fetchMessages + ->stubFor("/v3/history/sub-key/{$subKey}/channel/TheMessageHistoryChannelHD-ENCRYPTED") + ->withQuery([ + "include_meta" => "false", + "include_uuid" => "false", + "include_message_type" => "true", + "include_custom_message_type" => "false", + "pnsdk" => $this->encodedSdkName, + "uuid" => $this->pubnub_enc->getConfiguration()->getUserId(), + ]) + ->setResponseBody('{"status": 200, "error": false, "error_message": "", "channels": { + "TheMessageHistoryChannelHD-ENCRYPTED":[ + {"message":{"make":"Toyota"},"timetoken":"17165627054255980"} + ]}}'); + + $response = $fetchMessages->channels(self::ENCRYPTED_CHANNEL_NAME)->sync(); + + $this->assertEquals( + ['make' => 'Toyota'], + $response->getChannels()[self::ENCRYPTED_CHANNEL_NAME][0]->getMessage() + ); + } } // phpcs:ignore PSR1.Classes.ClassDeclaration diff --git a/tests/integrational/dataSync/DataSyncEndpointTest.php b/tests/integrational/dataSync/DataSyncEndpointTest.php index 04ab86d4..29c5688f 100644 --- a/tests/integrational/dataSync/DataSyncEndpointTest.php +++ b/tests/integrational/dataSync/DataSyncEndpointTest.php @@ -96,6 +96,20 @@ public function testDeleteEntityAcceptsEmptyBody(): void $this->assertTrue($result->isSuccess()); } + /** + * Only a delete is allowed to answer with nothing. Everything else owes a body, so an empty one + * is a broken response and has to be reported rather than turned into an empty record. + */ + public function testAnEmptyBodyOnAReadIsAnError(): void + { + $this->stub('/v1/datasync/subkeys/demo/entities/vehicle-1')->setResponseBody(''); + + $envelope = $this->pubnub_demo->dataSync()->getEntity()->entityId('vehicle-1')->envelope(); + + $this->assertTrue($envelope->isError()); + $this->assertNull($envelope->getResult()); + } + public function testGetEntity(): void { $this->stub('/v1/datasync/subkeys/demo/entities/vehicle-1') diff --git a/tests/integrational/dataSync/DataSyncSubscribeTest.php b/tests/integrational/dataSync/DataSyncSubscribeTest.php index b5ea154e..5281e4f4 100644 --- a/tests/integrational/dataSync/DataSyncSubscribeTest.php +++ b/tests/integrational/dataSync/DataSyncSubscribeTest.php @@ -3,6 +3,7 @@ namespace PubNubTests\integrational\dataSync; use PubNub\Callbacks\SubscribeCallback; +use PubNub\CryptoModule; use PubNub\Models\Consumer\DataSync\PNDataSyncEventResult; use PubNubTestCase; use PubNubTests\helpers\PsrStub; @@ -140,6 +141,35 @@ public function testMembershipEventReachesTheListener(): void $this->assertSame('user-1', $membership->getUserId()); } + /** + * A configured crypto module must not be handed a DataSync event. The decryptor only takes a + * string or an object, and an event payload is neither, so passing it through raised a + * TypeError that the subscribe loop did not catch and that took the whole listener down. + */ + public function testDataSyncEventSurvivesAConfiguredCryptoModule(): void + { + $this->pubnub_demo->getConfiguration()->setCryptoModule(CryptoModule::aesCbcCryptor('cipher-key', true)); + + $this->stubSubscribe($this->envelope([ + 'metadata' => [ + 'event' => 'create', + 'source' => 'data-sync', + 'type' => 'entity', + 'className' => 'vehicle', + 'classVersion' => '1', + ], + 'data' => ['id' => 'vehicle-1', 'payload' => ['make' => 'Toyota']], + ])); + + $callback = new DataSyncSubscribeCallback(); + $this->pubnub_demo->addListener($callback); + $this->pubnub_demo->subscribe()->channel('test')->execute(); + + $this->assertCount(1, $callback->dataSyncEvents); + $this->assertNotNull($callback->dataSyncEvents[0]->getEntity()); + $this->assertSame(['make' => 'Toyota'], $callback->dataSyncEvents[0]->getEntity()->getPayload()); + } + public function testUnrelatedMessageTypeFiveFallsThroughToMessage(): void { $this->stubSubscribe($this->envelope(['metadata' => ['source' => 'something-else']])); diff --git a/tests/unit/dataSync/DataSyncEventResultTest.php b/tests/unit/dataSync/DataSyncEventResultTest.php index 17d682e6..2d2c5508 100644 --- a/tests/unit/dataSync/DataSyncEventResultTest.php +++ b/tests/unit/dataSync/DataSyncEventResultTest.php @@ -81,6 +81,22 @@ public function testReadsTheClassLevel(): void $this->assertSame('SubKey', $event->getClassLevel()); } + /** + * The class, its version and its level all travel in the metadata rather than in the record, + * so all three have to be put back onto the record the event carries - otherwise an entity + * taken out of an event looks like it has no class level at all. + */ + public function testTheRecordCarriesTheClassLevelTheMetadataNamed(): void + { + $event = PNDataSyncEventResult::fromPayload($this->payload()); + + $this->assertNotNull($event); + $this->assertNotNull($event->getEntity()); + $this->assertSame('SubKey', $event->getEntity()->getEntityClassLevel()); + $this->assertSame('vehicle', $event->getEntity()->getEntityClass()); + $this->assertSame(1, $event->getEntity()->getEntityClassVersion()); + } + public function testMatchesEventAndTypeRegardlessOfCasing(): void { $event = PNDataSyncEventResult::fromPayload($this->payload([