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 73282134..c77ad9c2 100644 --- a/.pubnub.yml +++ b/.pubnub.yml @@ -314,6 +314,41 @@ features: - ACCESS-REVOKE-TOKEN - ACCESS-PARSE-TOKEN - ACCESS-SET-TOKEN + data-sync: + - 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 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/examples/DataSync.php b/examples/DataSync.php new file mode 100644 index 00000000..3f851c7c --- /dev/null +++ b/examples/DataSync.php @@ -0,0 +1,307 @@ +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); + + // 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 => ['get' => true, 'update' => true]]) + ->addDataSyncMembershipPatterns(['^' . $userId . ':.*$' => ['get' => 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: get=%s update=%s delete=%s\n", + $vehicleId, + var_export($vehiclePermissions->hasGet(), 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..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; @@ -32,6 +39,9 @@ class GrantToken extends Endpoint /** @var bool */ protected $sortParams = true; + /** @var array>> */ + protected $dataSyncProjections = []; + private $channels = []; private $groups = []; @@ -179,6 +189,131 @@ 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", "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__']], + * '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 (self::PROJECTION_TYPES 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 +359,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..16ff7916 --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Channel/DeleteChannel.php @@ -0,0 +1,59 @@ +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..f59c046d --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Channel/GetChannels.php @@ -0,0 +1,103 @@ +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'] = $this->entityClass; + } + + if ($this->entityClassVersion !== null) { + $params['entity_class_version'] = (string) $this->entityClassVersion; + } + + if (!empty($this->entityClassLevel)) { + $params['entity_class_level'] = $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..685d40dd --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/DataSyncCollectionEndpoint.php @@ -0,0 +1,167 @@ +|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. 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; + } + + /** + * Query parameters shared by every list endpoint, raw - DataSyncEndpoint::buildParams() + * encodes them once the signature has been taken. + * + * @return array + */ + protected function collectionParams(): array + { + $params = []; + + if (!empty($this->cursor)) { + $params['cursor'] = $this->cursor; + } + + if ($this->limit !== null) { + $params['limit'] = (string) $this->limit; + } + + if (!empty($this->filterFast)) { + $params['filter_fast'] = $this->filterFast; + } + + if (!empty($this->filter)) { + $params['filter'] = $this->filter; + } + + $sort = $this->buildSortValue(); + + if ($sort !== null) { + $params['sort'] = $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; + 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 new file mode 100644 index 00000000..225686f6 --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/DataSyncEndpoint.php @@ -0,0 +1,169 @@ +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(); + } + + /** + * 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 + */ + 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..13d9d04f --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Entity/DeleteEntity.php @@ -0,0 +1,59 @@ +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..8f2c9ac2 --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Entity/GetEntities.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"; 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'] = (string) $this->entityClass; + + if ($this->entityClassVersion !== null) { + $params['entity_class_version'] = (string) $this->entityClassVersion; + } + + if (!empty($this->entityClassLevel)) { + $params['entity_class_level'] = $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..f8898af6 --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Membership/DeleteMembership.php @@ -0,0 +1,59 @@ +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..054f3000 --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Membership/GetMemberships.php @@ -0,0 +1,98 @@ +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'] = $this->channelId; + } + + if (!empty($this->userId)) { + $params['user_id'] = $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..6276308d --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Relationship/DeleteRelationship.php @@ -0,0 +1,59 @@ +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..6f2c2776 --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/Relationship/GetRelationships.php @@ -0,0 +1,119 @@ +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'] = (string) $this->relationshipClass; + + if ($this->relationshipClassVersion !== null) { + $params['relationship_class_version'] = (string) $this->relationshipClassVersion; + } + + if (!empty($this->entityAId)) { + $params['entity_a_id'] = $this->entityAId; + } + + if (!empty($this->entityBId)) { + $params['entity_b_id'] = $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..528173fa --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/User/DeleteUser.php @@ -0,0 +1,59 @@ +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..a9237bae --- /dev/null +++ b/src/PubNub/Endpoints/DataSync/User/GetUsers.php @@ -0,0 +1,103 @@ +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'] = $this->entityClass; + } + + if ($this->entityClassVersion !== null) { + $params['entity_class_version'] = (string) $this->entityClassVersion; + } + + if (!empty($this->entityClassLevel)) { + $params['entity_class_level'] = $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..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; @@ -239,7 +245,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 +454,28 @@ 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 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); + + 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/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/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..78ec5622 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, @@ -295,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/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..83961931 --- /dev/null +++ b/src/PubNub/Models/Consumer/AccessManager/PNDataSyncProjectionScope.php @@ -0,0 +1,217 @@ + */ + private array $entities; + + /** @var array */ + private array $users; + + /** @var array */ + private array $channels; + + /** @var array */ + private array $relationships; + + /** @var array */ + private array $memberships; + + /** + * @param array $entities + * @param array $users + * @param array $channels + * @param array $relationships + * @param 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; + } + + /** + * Identifier to projection name, for entities. + * + * @return array + */ + 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. + * + * @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 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; + } + + public function getMembershipProjection(string $id): ?string + { + return $this->memberships[$id] ?? null; + } + + 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; + } + + /** + * @return array> + */ + public function toArray(): array + { + return [ + 'entities' => $this->entities, + 'users' => $this->users, + 'channels' => $this->channels, + '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 + { + $families = [ + 'entities' => [], + 'users' => [], + 'channels' => [], + '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; + + if (array_key_exists($type, $families)) { + $families[$type][$id] = (string) $projectionName; + } + } + + return new self( + $families['entities'], + $families['users'], + $families['channels'], + $families['relationships'], + $families['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 @@ +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/PNDataSyncDeleteResult.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncDeleteResult.php new file mode 100644 index 00000000..caaf0aee --- /dev/null +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncDeleteResult.php @@ -0,0 +1,30 @@ + $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..bf2c9850 --- /dev/null +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncEntitiesResult.php @@ -0,0 +1,26 @@ +data; + } + + /** + * @param array $item + */ + protected static function recordFromPayload(array $item): PNDataSyncEntity + { + return PNDataSyncEntity::fromPayload($item); + } +} diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncEntity.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncEntity.php new file mode 100644 index 00000000..6a23d42b --- /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..3072a990 --- /dev/null +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncEntityResult.php @@ -0,0 +1,23 @@ +data; + } + + /** + * @param array $data + */ + protected static function recordFromPayload(array $data): PNDataSyncEntity + { + return 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..8544cd86 --- /dev/null +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncEventResult.php @@ -0,0 +1,321 @@ +version = $version; + $this->event = $event; + $this->source = $source; + $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; + $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; + } + + /** + * One of "entity", "user", "channel", "relationship" or "membership". + */ + public function getType(): ?string + { + return $this->type; + } + + public function getClassName(): ?string + { + return $this->className; + } + + public function getClassVersion(): ?int + { + return $this->classVersion; + } + + /** + * 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 + { + return $this->entity; + } + + /** + * 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; + } + + 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 = PNDataSyncValue::stringOrNull($metadata, 'className'); + $classVersion = PNDataSyncValue::intOrNull($metadata, 'classVersion'); + $classLevel = PNDataSyncValue::stringOrNull($metadata, 'classLevel'); + + $data = PNDataSyncValue::arrayOrNull($payload, 'data') ?? []; + $id = PNDataSyncValue::stringOrNull($data, 'id'); + + // 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, + self::SOURCE, + $type, + $className, + $classVersion, + $classLevel, + null, + null, + null, + $id, + PNDataSyncValue::stringOrNull($data, 'deletedAt'), + $channel, + $subscription, + $timetoken + ); + } + + $entity = null; + $relationship = null; + $membership = null; + + // The class name and version live in the metadata rather than in the record itself. + if (in_array($typeName, self::ENTITY_TYPES, true)) { + $entity = PNDataSyncEntity::fromPayload(array_merge($data, [ + 'entityClass' => $className, + 'entityClassVersion' => $classVersion, + 'entityClassLevel' => $classLevel, + ])); + } 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( + $version, + $event, + self::SOURCE, + $type, + $className, + $classVersion, + $classLevel, + $entity, + $relationship, + $membership, + $id, + null, + $channel, + $subscription, + $timetoken + ); + } +} diff --git a/src/PubNub/Models/Consumer/DataSync/PNDataSyncMembership.php b/src/PubNub/Models/Consumer/DataSync/PNDataSyncMembership.php new file mode 100644 index 00000000..b9685694 --- /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..0447fb2c --- /dev/null +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncMembershipResult.php @@ -0,0 +1,23 @@ +data; + } + + /** + * @param array $data + */ + protected static function recordFromPayload(array $data): PNDataSyncMembership + { + return 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..98895313 --- /dev/null +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncMembershipsResult.php @@ -0,0 +1,26 @@ +data; + } + + /** + * @param array $item + */ + protected static function recordFromPayload(array $item): PNDataSyncMembership + { + return PNDataSyncMembership::fromPayload($item); + } +} 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/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 new file mode 100644 index 00000000..c0f5a155 --- /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..c55c200f --- /dev/null +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncRelationshipResult.php @@ -0,0 +1,23 @@ +data; + } + + /** + * @param array $data + */ + protected static function recordFromPayload(array $data): PNDataSyncRelationship + { + return 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..d1d5742a --- /dev/null +++ b/src/PubNub/Models/Consumer/DataSync/PNDataSyncRelationshipsResult.php @@ -0,0 +1,26 @@ +data; + } + + /** + * @param array $item + */ + protected static function recordFromPayload(array $item): PNDataSyncRelationship + { + return PNDataSyncRelationship::fromPayload($item); + } +} 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/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/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..4ca20fcd 100755 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -22,6 +22,13 @@ 'SUBSCRIBE_KEY', 'SUBSCRIBE_PAM_KEY', 'UUID_MOCK', + 'DATASYNC_SUBSCRIBE_KEY', + 'DATASYNC_PUBLISH_KEY', + 'DATASYNC_SECRET_KEY', + 'DATASYNC_ENTITY_CLASS', + 'DATASYNC_ENTITY_CLASS_PROJECTIONS', + '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..fe527416 --- /dev/null +++ b/tests/functional/dataSync/DataSyncGrantTokenTest.php @@ -0,0 +1,142 @@ +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-.*$']); + } + + /** + * 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( + $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..415d1ea4 --- /dev/null +++ b/tests/functional/dataSync/DataSyncRequestTest.php @@ -0,0 +1,520 @@ +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']); + } + + /** + * 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']); + } + + /** + * 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() + ->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/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/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/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 new file mode 100644 index 00000000..29c5688f --- /dev/null +++ b/tests/integrational/dataSync/DataSyncEndpointTest.php @@ -0,0 +1,400 @@ +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()); + } + + /** + * 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') + ->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/DataSyncEventTest.php b/tests/integrational/dataSync/DataSyncEventTest.php new file mode 100644 index 00000000..d857c265 --- /dev/null +++ b/tests/integrational/dataSync/DataSyncEventTest.php @@ -0,0 +1,426 @@ +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()); + // Ten seconds is the default and a loaded CI runner occasionally needs more than that. + $config->setNonSubscribeRequestTimeout(30); + + $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()); + } + + /** + * 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. + */ + 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(); + + // 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; + } + + 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(); + + $this->readableNow( + fn() => $this->pubnub->dataSync()->getUser()->userId($userId)->sync(), + 'the user fixture' + ); + + 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(); + + $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 new file mode 100644 index 00000000..b9154c62 --- /dev/null +++ b/tests/integrational/dataSync/DataSyncGrantTest.php @@ -0,0 +1,384 @@ +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); + // 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); + } + + 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; + + // 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; + } + + /** + * @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' + ); + } + + /** + * 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); + $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/DataSyncLifecycleTest.php b/tests/integrational/dataSync/DataSyncLifecycleTest.php new file mode 100644 index 00000000..8902aa08 --- /dev/null +++ b/tests/integrational/dataSync/DataSyncLifecycleTest.php @@ -0,0 +1,493 @@ +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'); + // Ten seconds is the default and a loaded CI runner occasionally needs more than that. + $config->setNonSubscribeRequestTimeout(30); + + $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->readEventually( + fn() => $this->pubnub->dataSync()->getEntity()->entityId($entityId)->sync(), + null, + 'the new entity' + ); + + $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()); + } + } + + /** + * 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->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(); + } + } + + /** + * 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 { + // 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()); + $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. + */ + 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->readEventually( + fn() => $this->pubnub->dataSync()->getRelationship() + ->relationshipId($relationshipId) + ->sync(), + null, + 'the new relationship' + ); + + $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, or to list, and returns its id. + */ + private function createEntity(string $status = 'active'): string + { + $entityId = 'php-sdk-lifecycle-' . uniqid(); + + $this->pubnub->dataSync()->createEntity() + ->entityId($entityId) + ->entityClass($this->entityClass) + ->entityClassVersion(1) + ->status($status) + ->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 new file mode 100644 index 00000000..14f682c0 --- /dev/null +++ b/tests/integrational/dataSync/DataSyncPredefinedLifecycleTest.php @@ -0,0 +1,386 @@ +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'); + // Ten seconds is the default and a loaded CI runner occasionally needs more than that. + $config->setNonSubscribeRequestTimeout(30); + + $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->readEventually( + fn() => $this->pubnub->dataSync()->getUser()->userId($userId)->sync(), + null, + 'the new user' + ); + + $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->assertUserIsListed($userId); + } 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->readEventually( + fn() => $this->pubnub->dataSync()->getChannel()->channelId($channelId)->sync(), + null, + 'the new channel' + ); + + $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->assertChannelIsListed($channelId); + } 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->readEventually( + fn() => $this->pubnub->dataSync()->getMembership() + ->membershipId($membershipId) + ->sync(), + null, + 'the new membership' + ); + + $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->assertMembershipIsListed( + $membershipId, + ['userId' => $userId], + 'the membership should be listed among the channels the user belongs to' + ); + $this->assertMembershipIsListed( + $membershipId, + ['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. Retried as well, because the listing index picks a record up a moment after + * a direct read of it already works. + */ + private function assertUserIsListed(string $userId): void + { + $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()); + $this->assertContains($userId, $this->idsOf($listed->getData())); + } + + private function assertChannelIsListed(string $channelId): void + { + $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()); + $this->assertContains($channelId, $this->idsOf($listed->getData())); + } + + /** + * @param array{userId?: string, channelId?: string} $side + */ + private function assertMembershipIsListed(string $membershipId, array $side, string $message): void + { + $listed = $this->readEventually( + function () use ($side) { + $endpoint = $this->pubnub->dataSync()->getMemberships()->limit(100); + + if (isset($side['userId'])) { + $endpoint->userId($side['userId']); + } + + if (isset($side['channelId'])) { + $endpoint->channelId($side['channelId']); + } + + 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 + { + $userId = 'php-sdk-user-' . uniqid(); + + $this->pubnub->dataSync()->createUser() + ->userId($userId) + ->entityClassVersion(1) + ->status('active') + ->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; + } + + private function createChannel(): string + { + $channelId = 'php-sdk-channel-' . uniqid(); + + $this->pubnub->dataSync()->createChannel() + ->channelId($channelId) + ->entityClassVersion(1) + ->status('active') + ->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 new file mode 100644 index 00000000..648e49d7 --- /dev/null +++ b/tests/integrational/dataSync/DataSyncProjectionTest.php @@ -0,0 +1,275 @@ + '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); + // 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); + } + + 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; + + // 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; + } + + /** + * @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/integrational/dataSync/DataSyncSubscribeTest.php b/tests/integrational/dataSync/DataSyncSubscribeTest.php new file mode 100644 index 00000000..5281e4f4 --- /dev/null +++ b/tests/integrational/dataSync/DataSyncSubscribeTest.php @@ -0,0 +1,233 @@ +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()); + } + + /** + * 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']])); + + $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/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; + } +} 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()); + } +} diff --git a/tests/unit/dataSync/DataSyncEventResultTest.php b/tests/unit/dataSync/DataSyncEventResultTest.php new file mode 100644 index 00000000..2d2c5508 --- /dev/null +++ b/tests/unit/dataSync/DataSyncEventResultTest.php @@ -0,0 +1,319 @@ + $overrides + * @return array + */ + private function payload(array $overrides = []): array + { + return array_merge([ + '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'], + '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 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('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([ + '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 + { + $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()); + $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 + { + $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..80cd8c8f --- /dev/null +++ b/tests/unit/dataSync/DataSyncTokenParseTest.php @@ -0,0 +1,226 @@ + $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 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([ + '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); + } +}