diff --git a/.env.dist b/.env.dist new file mode 100644 index 00000000..2d857844 --- /dev/null +++ b/.env.dist @@ -0,0 +1,17 @@ +# PubNub API Keys for Local Testing +# Copy this file to .env.dev and fill in your actual keys + +# Required for most tests +PUBLISH_KEY=your-publish-key-here +SUBSCRIBE_KEY=your-subscribe-key-here + +# Optional - for some advanced tests +SECRET_KEY=your-secret-key-here + +# Optional - for PAM (Access Manager) tests +PUBLISH_PAM_KEY=your-pam-publish-key-here +SUBSCRIBE_PAM_KEY=your-pam-subscribe-key-here +SECRET_PAM_KEY=your-pam-secret-key-here + +# Optional - UUID for mock tests (defaults to "UUID_MOCK") +UUID_MOCK=test-user \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0bf2e3ab..f8fcd378 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,2 @@ -* @seba-aln @xavrax @marcin-cebo -README.md @techwritermat @kazydek @seba-aln @xavrax +* @jakub-grzesiowski @marcin-cebo @xavrax @seba-aln +README.md @techwritermat @kazydek @jakub-grzesiowski @marcin-cebo @xavrax @seba-aln diff --git a/.gitignore b/.gitignore index 84cc9144..c1f54417 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,12 @@ .DS_Store coverage.clover +# Environment files (for local development) # +############################################# +.env.dev +.env.test +.env.prod + # GitHub Actions # ################## .github/.release diff --git a/composer.json b/composer.json index a6c20455..8b8c34ed 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,8 @@ "phpunit/phpunit": "^9.5", "squizlabs/php_codesniffer": "^3.7", "phpstan/phpstan": "^1.8", - "behat/behat": "^3.14" + "behat/behat": "^3.14", + "vlucas/phpdotenv": "^5.6" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 5b7342ae..c49a43bb 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "049f9de47ba2d97bf73027fc71176620", + "content-hash": "d5eed0729a61b30d63c169f7c3fd58a1", "packages": [ { "name": "guzzlehttp/guzzle", @@ -1166,6 +1166,68 @@ ], "time": "2022-12-30T00:23:10+00:00" }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.3", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "3ba905c11371512af9d9bdd27d99b782216b6945" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945", + "reference": "3ba905c11371512af9d9bdd27d99b782216b6945", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.3" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2024-07-20T21:45:45+00:00" + }, { "name": "monolog/monolog", "version": "3.8.1", @@ -1505,6 +1567,81 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpoption/phpoption", + "version": "1.9.4", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", + "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2025-08-21T11:53:16+00:00" + }, { "name": "phpstan/phpstan", "version": "1.12.21", @@ -3925,6 +4062,90 @@ ], "time": "2024-09-09T11:45:10+00:00" }, + { + "name": "symfony/polyfill-php80", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-01-02T08:10:11+00:00" + }, { "name": "symfony/service-contracts", "version": "v3.5.1", @@ -4465,6 +4686,90 @@ } ], "time": "2024-03-03T12:36:25+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.2", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.3", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.3", + "symfony/polyfill-ctype": "^1.24", + "symfony/polyfill-mbstring": "^1.24", + "symfony/polyfill-php80": "^1.24" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2025-04-30T23:37:27+00:00" } ], "aliases": [], diff --git a/examples/AccessManager.php b/examples/AccessManager.php index 857eb4c4..ad95ebec 100644 --- a/examples/AccessManager.php +++ b/examples/AccessManager.php @@ -12,6 +12,29 @@ $subscribeKey = getenv('SUBSCRIBE_PAM_KEY') ?? 'demo'; $secretKey = getenv('SECRET_PAM_KEY') ?? 'demo'; +// Generate unique channel prefix for test isolation (prevents PAM propagation issues in CI/CD) +$testId = uniqid('test-', true); +$channelPrefix = getenv('TEST_CHANNEL_PREFIX') ?? $testId; + +// Define channel names with unique prefix +$publicChannel = "{$channelPrefix}-public-channel"; +$readOnlyChannel = "{$channelPrefix}-read-only-channel"; +$privateChannel = "{$channelPrefix}-private-channel"; +$adminOnlyChannel = "{$channelPrefix}-admin-only-channel"; + +// Define channel group with unique prefix +$userGroup = "{$channelPrefix}-user-group"; + +echo "🔑 Test Configuration\n"; +echo "-------------------\n"; +echo "Test ID: {$testId}\n"; +echo "Channel Prefix: {$channelPrefix}\n"; +echo "Channels:\n"; +echo " - {$publicChannel}\n"; +echo " - {$readOnlyChannel}\n"; +echo " - {$privateChannel}\n"; +echo " - {$adminOnlyChannel}\n\n"; + // Admin instance with full access (includes secret key) $adminConfig = new PNConfiguration(); $adminConfig->setPublishKey($publishKey); @@ -44,7 +67,7 @@ function printResult($testName, $success, $message = '') // snippet.prepare_metadata // Create channel metadata for demo channels -$channels = ['public-channel', 'read-only-channel', 'private-channel', 'admin-only-channel']; +$channels = [$publicChannel, $readOnlyChannel, $privateChannel, $adminOnlyChannel]; foreach ($channels as $channel) { try { $admin->setChannelMetadata() @@ -52,7 +75,7 @@ function printResult($testName, $success, $message = '') ->setName(ucwords(str_replace('-', ' ', $channel))) ->setDescription("Demo channel for access manager testing - " . $channel) ->setCustom([ - 'type' => $channel === 'admin-only-channel' ? 'admin' : 'user', + 'type' => $channel === $adminOnlyChannel ? 'admin' : 'user', 'created' => date('Y-m-d H:i:s'), 'demo' => true ]) @@ -119,12 +142,12 @@ function printResult($testName, $success, $message = '') ->ttl(60) // 60 minutes ->authorizedUuid('regular-user') // Restrict to specific user ->addChannelResources([ - 'public-channel' => ['read' => true, 'write' => true], // Full access - 'read-only-channel' => ['read' => true], // Read only - no write - 'private-channel' => ['read' => true, 'write' => true, 'manage' => true] // Full access including manage + $publicChannel => ['read' => true, 'write' => true], // Full access + $readOnlyChannel => ['read' => true], // Read only - no write + $privateChannel => ['read' => true, 'write' => true, 'manage' => true] // Full access including manage ]) ->addChannelGroupResources([ - 'user-group' => ['read' => true] // Read only for channel groups + $userGroup => ['read' => true] // Read only for channel groups ]) ->addUuidResources([ 'regular-user' => ['get' => true, 'update' => true], // Self metadata access @@ -162,7 +185,7 @@ function printResult($testName, $success, $message = '') // Show channel permissions echo "\nChannel Permissions:\n"; - foreach (['public-channel', 'read-only-channel', 'private-channel'] as $channel) { + foreach ([$publicChannel, $readOnlyChannel, $privateChannel] as $channel) { $permissions = $parsedToken->getChannelResource($channel); if ($permissions) { echo "- $channel: "; @@ -203,7 +226,7 @@ function printResult($testName, $success, $message = '') // snippet.access_denied_without_token try { $user->publish() - ->channel('public-channel') + ->channel($publicChannel) ->message(['text' => 'Hello without token!']) ->sync(); printResult("User publish to public-channel WITHOUT token", false, "Should have failed but succeeded"); @@ -213,7 +236,7 @@ function printResult($testName, $success, $message = '') try { $user->history() - ->channel('public-channel') + ->channel($publicChannel) ->count(1) ->sync(); printResult("User read from public-channel WITHOUT token", false, "Should have failed but succeeded"); @@ -236,7 +259,7 @@ function printResult($testName, $success, $message = '') // Test allowed operations try { $result = $user->publish() - ->channel('public-channel') + ->channel($publicChannel) ->message(['text' => 'Hello with token!', 'timestamp' => time()]) ->sync(); printResult("User publish to public-channel WITH token", true, "Message published successfully"); @@ -246,7 +269,7 @@ function printResult($testName, $success, $message = '') try { $result = $user->history() - ->channel('public-channel') + ->channel($publicChannel) ->count(5) ->sync(); printResult("User read from public-channel WITH token", true, "History retrieved successfully"); @@ -256,7 +279,7 @@ function printResult($testName, $success, $message = '') try { $result = $user->history() - ->channel('private-channel') + ->channel($privateChannel) ->count(5) ->sync(); printResult("User read from private-channel WITH token", true, "History retrieved successfully"); @@ -275,7 +298,7 @@ function printResult($testName, $success, $message = '') // Test read-only channel (can read but not write) try { $user->history() - ->channel('read-only-channel') + ->channel($readOnlyChannel) ->count(1) ->sync(); printResult("User read from read-only-channel WITH token", true, "Read access granted"); @@ -285,7 +308,7 @@ function printResult($testName, $success, $message = '') try { $user->publish() - ->channel('read-only-channel') + ->channel($readOnlyChannel) ->message(['text' => 'Trying to write to read-only channel']) ->sync(); printResult("User publish to read-only-channel WITH token", false, "Should have failed but succeeded"); @@ -296,7 +319,7 @@ function printResult($testName, $success, $message = '') // Test channel not in token (should fail) try { $user->publish() - ->channel('admin-only-channel') + ->channel($adminOnlyChannel) ->message(['text' => 'Trying to access admin channel']) ->sync(); printResult("User publish to admin-only-channel WITH token", false, "Should have failed but succeeded"); @@ -306,7 +329,7 @@ function printResult($testName, $success, $message = '') try { $user->history() - ->channel('admin-only-channel') + ->channel($adminOnlyChannel) ->count(1) ->sync(); printResult("User read from admin-only-channel WITH token", false, "Should have failed but succeeded"); @@ -371,7 +394,7 @@ function printResult($testName, $success, $message = '') // snippet.admin_unrestricted_access try { $result = $admin->publish() - ->channel('admin-only-channel') + ->channel($adminOnlyChannel) ->message(['text' => 'Admin message', 'timestamp' => time()]) ->sync(); printResult("Admin publish to admin-only-channel", true, "Admin has unrestricted access"); @@ -381,7 +404,7 @@ function printResult($testName, $success, $message = '') try { $result = $admin->history() - ->channel('admin-only-channel') + ->channel($adminOnlyChannel) ->count(5) ->sync(); printResult("Admin read from admin-only-channel", true, "Admin has unrestricted access"); @@ -418,7 +441,7 @@ function printResult($testName, $success, $message = '') // Test user access after revocation (should fail) try { $publishResult = $user->publish() - ->channel('public-channel') + ->channel($publicChannel) ->message(['text' => 'Hello after revocation!']) ->sync(); // print_r($publishResult); diff --git a/examples/Configuration.php b/examples/Configuration.php index 8e4a53fa..8f26f77a 100644 --- a/examples/Configuration.php +++ b/examples/Configuration.php @@ -15,18 +15,18 @@ $pnConfiguration = new PNConfiguration(); // Set subscribe key (required) -$pnConfiguration->setSubscribeKey(getenv('SUBSCRIBE_KEY') ?? 'demo'); +$pnConfiguration->setSubscribeKey(getenv('SUBSCRIBE_KEY') ?: 'demo'); // Set publish key (only required if publishing) -$pnConfiguration->setPublishKey(getenv('PUBLISH_KEY') ?? 'demo'); +$pnConfiguration->setPublishKey(getenv('PUBLISH_KEY') ?: 'demo'); // Set UUID (required to connect) $pnConfiguration->setUserId('php-config-demo-user'); // snippet.end // Verify configuration was set correctly -assert($pnConfiguration->getSubscribeKey() === (getenv('SUBSCRIBE_KEY') ?? 'demo')); -assert($pnConfiguration->getPublishKey() === (getenv('PUBLISH_KEY') ?? 'demo')); +assert($pnConfiguration->getSubscribeKey() === (getenv('SUBSCRIBE_KEY') ?: 'demo')); +assert($pnConfiguration->getPublishKey() === (getenv('PUBLISH_KEY') ?: 'demo')); assert($pnConfiguration->getUserId() === 'php-config-demo-user'); // snippet.basic_configuration @@ -34,10 +34,10 @@ $pnConfiguration = new PNConfiguration(); // Set subscribe key (required) -$pnConfiguration->setSubscribeKey(getenv('SUBSCRIBE_KEY') ?? 'demo'); +$pnConfiguration->setSubscribeKey(getenv('SUBSCRIBE_KEY') ?: 'demo'); // Set publish key (only required if publishing) -$pnConfiguration->setPublishKey(getenv('PUBLISH_KEY') ?? 'demo'); +$pnConfiguration->setPublishKey(getenv('PUBLISH_KEY') ?: 'demo'); // Set UUID (required to connect) $pnConfiguration->setUserId("php-sdk-example-user"); @@ -91,8 +91,8 @@ // snippet.end // Verify configuration values -assert($pnConfiguration->getSubscribeKey() === getenv('SUBSCRIBE_KEY') ?? 'demo'); -assert($pnConfiguration->getPublishKey() === getenv('PUBLISH_KEY') ?? 'demo'); +assert($pnConfiguration->getSubscribeKey() === (getenv('SUBSCRIBE_KEY') ?: 'demo')); +assert($pnConfiguration->getPublishKey() === (getenv('PUBLISH_KEY') ?: 'demo')); assert($pnConfiguration->getUserId() === "php-sdk-example-user"); assert($pnConfiguration->getConnectTimeout() === 10); assert($pnConfiguration->getSubscribeTimeout() === 310); @@ -104,8 +104,8 @@ // snippet.init_basic $pnconf = new PNConfiguration(); -$pnconf->setSubscribeKey(getenv('SUBSCRIBE_KEY') ?? 'demo'); -$pnconf->setPublishKey(getenv('PUBLISH_KEY') ?? 'demo'); +$pnconf->setSubscribeKey(getenv('SUBSCRIBE_KEY') ?: 'demo'); +$pnconf->setPublishKey(getenv('PUBLISH_KEY') ?: 'demo'); $pnconf->setSecure(false); $pnconf->setUserId("myUniqueUserId"); $pubnub = new PubNub($pnconf); @@ -113,26 +113,26 @@ // snippet.end // Verify configuration -assert($pnconf->getSubscribeKey() === getenv('SUBSCRIBE_KEY') ?? 'demo'); -assert($pnconf->getPublishKey() === getenv('PUBLISH_KEY') ?? 'demo'); +assert($pnconf->getSubscribeKey() === (getenv('SUBSCRIBE_KEY') ?: 'demo')); +assert($pnconf->getPublishKey() === (getenv('PUBLISH_KEY') ?: 'demo')); assert($pnconf->getUserId() === "myUniqueUserId"); assert($pubnub instanceof PubNub); // snippet.init_access_manager $pnConfiguration = new PNConfiguration(); -$pnConfiguration->setSubscribeKey(getenv('SUBSCRIBE_KEY') ?? 'demo'); -$pnConfiguration->setPublishKey(getenv('PUBLISH_KEY') ?? 'demo'); +$pnConfiguration->setSubscribeKey(getenv('SUBSCRIBE_KEY') ?: 'demo'); +$pnConfiguration->setPublishKey(getenv('PUBLISH_KEY') ?: 'demo'); //NOTE: only server side should have secret key -$pnConfiguration->setSecretKey(getenv('SECRET_KEY') ?? 'demo'); +$pnConfiguration->setSecretKey(getenv('SECRET_KEY') ?: 'demo'); $pnConfiguration->setUserId("myUniqueUserId"); $pubnub = new PubNub($pnConfiguration); // snippet.end // Verify configuration -assert($pnConfiguration->getSubscribeKey() === getenv('SUBSCRIBE_KEY') ?? 'demo'); -assert($pnConfiguration->getPublishKey() === getenv('PUBLISH_KEY') ?? 'demo'); -assert($pnConfiguration->getSecretKey() === getenv('SECRET_KEY') ?? 'demo'); +assert($pnConfiguration->getSubscribeKey() === (getenv('SUBSCRIBE_KEY') ?: 'demo')); +assert($pnConfiguration->getPublishKey() === (getenv('PUBLISH_KEY') ?: 'demo')); +assert($pnConfiguration->getSecretKey() === (getenv('SECRET_KEY') ?: 'demo')); assert($pnConfiguration->getUserId() === "myUniqueUserId"); assert($pubnub instanceof PubNub); @@ -146,7 +146,7 @@ function status($pubnub, $status) } elseif ($status->getCategory() === PNStatusCategory::PNConnectedCategory) { // Connect event. You can do stuff like publish, and know you'll get it } elseif ($status->getCategory() === PNStatusCategory::PNDecryptionErrorCategory) { - // Handle message decryption error. + // Handle message decryption error. } } @@ -162,8 +162,8 @@ function presence($pubnub, $presence) $pnconf = new PNConfiguration(); -$pnconf->setSubscribeKey(getenv('SUBSCRIBE_KEY') ?? 'demo'); -$pnconf->setPublishKey(getenv('PUBLISH_KEY') ?? 'demo'); +$pnconf->setSubscribeKey(getenv('SUBSCRIBE_KEY') ?: 'demo'); +$pnconf->setPublishKey(getenv('PUBLISH_KEY') ?: 'demo'); $pnconf->setUserId("event-listener-demo-user"); $pubnub = new PubNub($pnconf); @@ -195,7 +195,7 @@ function presence($pubnub, $presence) // snippet.set_filter_expression $pnconf = new PNConfiguration(); -$pnconf->setSubscribeKey(getenv('SUBSCRIBE_KEY') ?? 'demo'); +$pnconf->setSubscribeKey(getenv('SUBSCRIBE_KEY') ?: 'demo'); $pnconf->setUserId("filter-demo-user"); $pnconf->setFilterExpression("userid == 'my_userid'"); @@ -203,13 +203,7 @@ function presence($pubnub, $presence) // snippet.end // Verify configuration -assert($pnconf->getSubscribeKey() === "my_sub_key"); -assert($pnconf->getPublishKey() === "my_pub_key"); -assert($pnconf->getUserId() === "event-listener-demo-user"); -assert($pubnub instanceof PubNub); -// Verify callback instance -assert($subscribeCallback instanceof SubscribeCallback); -// Verify configuration -assert($pnconf->getSubscribeKey() === "my_sub_key"); +assert($pnconf->getSubscribeKey() === (getenv('SUBSCRIBE_KEY') ?: 'demo')); +assert($pnconf->getUserId() === "filter-demo-user"); assert($pnconf->getFilterExpression() === "userid == 'my_userid'"); assert($pubnub instanceof PubNub); diff --git a/examples/basic_usage/presence.php b/examples/basic_usage/presence.php index e83acc04..0b73fd61 100644 --- a/examples/basic_usage/presence.php +++ b/examples/basic_usage/presence.php @@ -23,6 +23,8 @@ ->channels(["my_channel", "demo"]) ->includeUuids(true) // Include the UUIDs of connected clients ->includeState(false) // Don't include state information + ->limit(100) // Optional: Limit occupants returned per channel (0-1000, default: 1000) + ->offset(0) // Optional: Skip first N occupants for pagination (default: 0) ->sync(); // Display total counts diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e2e479a6..c15ef490 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -5985,11 +5985,6 @@ parameters: count: 1 path: tests/integrational/HereNowTest.php - - - message: "#^Method Tests\\\\Integrational\\\\HereNowTest\\:\\:testSuperCallTest\\(\\) has no return type specified\\.$#" - count: 1 - path: tests/integrational/HereNowTest.php - - message: "#^Parameter \\#1 \\$subscribeKey of method PubNub\\\\PNConfiguration\\:\\:setSubscribeKey\\(\\) expects string, null given\\.$#" count: 1 diff --git a/src/PubNub/Endpoints/Endpoint.php b/src/PubNub/Endpoints/Endpoint.php index 62f7001d..efc38ba5 100755 --- a/src/PubNub/Endpoints/Endpoint.php +++ b/src/PubNub/Endpoints/Endpoint.php @@ -140,7 +140,7 @@ protected function validatePublishKey() { $publishKey = $this->pubnub->getConfiguration()->getPublishKey(); - if (strlen($publishKey) === 0) { + if ($publishKey === null || strlen($publishKey) === 0) { throw new PubNubValidationException("Publish Key not configured"); } } diff --git a/src/PubNub/Endpoints/Presence/HereNow.php b/src/PubNub/Endpoints/Presence/HereNow.php index 811470b2..e3cc9a1d 100644 --- a/src/PubNub/Endpoints/Presence/HereNow.php +++ b/src/PubNub/Endpoints/Presence/HereNow.php @@ -13,6 +13,7 @@ class HereNow extends Endpoint { protected const PATH = "/v2/presence/sub-key/%s/channel/%s"; protected const GLOBAL_PATH = "/v2/presence/sub-key/%s"; + protected const MAX_CHANNEL_OCCUPANTS_LIMIT = 1000; /** @var string[] */ protected $channels = []; @@ -26,6 +27,10 @@ class HereNow extends Endpoint /** @var bool */ protected $includeUuids = true; + protected int $limit = self::MAX_CHANNEL_OCCUPANTS_LIMIT; + + protected ?int $offset = null; + /** * @param string|string[] $channels * @return $this @@ -70,6 +75,28 @@ public function includeUuids($includeUuids) return $this; } + /** + * @param int $limit Maximum number of occupants to return per channel (0-1000) + * @return $this + */ + public function limit($limit): self + { + $this->limit = $limit; + + return $this; + } + + /** + * @param int|null $offset Zero-based starting index for pagination + * @return $this + */ + public function offset($offset): self + { + $this->offset = $offset; + + return $this; + } + /** * @throws PubNubValidationException */ @@ -97,6 +124,12 @@ protected function customParams() $params['disable-uuids'] = "1"; } + $params['limit'] = (string) $this->limit; + + if ($this->offset !== null) { + $params['offset'] = (string) $this->offset; + } + return $params; } diff --git a/tests/PubNubTestCase.php b/tests/PubNubTestCase.php index 064b3845..7f8f44cc 100755 --- a/tests/PubNubTestCase.php +++ b/tests/PubNubTestCase.php @@ -70,12 +70,12 @@ protected function fakeSignature($params, $httpMethod, $timestamp, $publishKey, public function setUp(): void { - $publishKey = getenv("PUBLISH_KEY"); - $subscribeKey = getenv("SUBSCRIBE_KEY"); - $publishKeyPam = getenv("PUBLISH_PAM_KEY"); - $subscribeKeyPam = getenv("SUBSCRIBE_PAM_KEY"); - $secretKeyPam = getenv("SECRET_PAM_KEY"); - $uuidMock = getenv("UUID_MOCK") ? getenv("UUID_MOCK") : "UUID_MOCK"; + $publishKey = getenv("PUBLISH_KEY") ?: ""; + $subscribeKey = getenv("SUBSCRIBE_KEY") ?: ""; + $publishKeyPam = getenv("PUBLISH_PAM_KEY") ?: ""; + $subscribeKeyPam = getenv("SUBSCRIBE_PAM_KEY") ?: ""; + $secretKeyPam = getenv("SECRET_PAM_KEY") ?: ""; + $uuidMock = getenv("UUID_MOCK") ?: "UUID_MOCK"; $logger = new Logger('PubNub'); $logger->pushHandler(new ErrorLogHandler()); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index fdf2fcb5..126ad3b1 100755 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -5,6 +5,32 @@ // Enable all errors error_reporting(E_ALL); +// Load environment variables from .env.dev file (for local development) +// This allows developers to set PUBLISH_KEY, SUBSCRIBE_KEY, etc. locally +$envFile = dirname(__DIR__) . '/.env.dev'; +if (file_exists($envFile)) { + $dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__), '.env.dev'); + $dotenv->safeLoad(); // safeLoad() won't throw if .env.dev is missing + + // Dotenv only populates $_ENV and $_SERVER, but tests use getenv() + // Manually populate putenv() so getenv() works throughout the test suite + $requiredEnvKeys = [ + 'PUBLISH_KEY', + 'PUBLISH_PAM_KEY', + 'SECRET_KEY', + 'SECRET_PAM_KEY', + 'SUBSCRIBE_KEY', + 'SUBSCRIBE_PAM_KEY', + 'UUID_MOCK', + ]; + + foreach ($requiredEnvKeys as $key) { + if (isset($_ENV[$key])) { + putenv("$key={$_ENV[$key]}"); + } + } +} + require_once(__DIR__ . '/PubNubTestCase.php'); if (!class_exists('Thread')) { diff --git a/tests/e2e/HereNowE2eTest.php b/tests/e2e/HereNowE2eTest.php new file mode 100644 index 00000000..c5118f24 --- /dev/null +++ b/tests/e2e/HereNowE2eTest.php @@ -0,0 +1,156 @@ +generateTestChannels(2); + + try { + // Subscribe 5 clients to channel1 + $uuids1 = $this->subscribeBackgroundClients($channels[0], $channel1ClientsCount, 30, "ch1-user-"); + + // Subscribe 3 clients to channel2 + $uuids2 = $this->subscribeBackgroundClients($channels[1], $channel2ClientsCount, 30, "ch2-user-"); + + // Wait for presence to propagate for both channels + $this->assertTrue( + $this->waitForOccupancy($this->pubnub, $channels[0], $channel1ClientsCount, 15, 1), + "Failed to establish presence for channel1" + ); + $this->assertTrue( + $this->waitForOccupancy($this->pubnub, $channels[1], $channel2ClientsCount, 15, 1), + "Failed to establish presence for channel2" + ); + + // Test limit on multiple channels + $response = $this->pubnub->hereNow() + ->channels($channels) + ->includeUuids(true) + ->limit($testLimit) + ->sync(); + + $this->assertEquals(2, $response->getTotalChannels()); + $this->assertEquals($totalClientsCount, $response->getTotalOccupancy()); + + // Find channels in response (order may vary) + $channelDataMap = []; + foreach ($response->getChannels() as $channelData) { + $channelDataMap[$channelData->getChannelName()] = $channelData; + } + + // Verify channel1 data + $this->assertArrayHasKey($channels[0], $channelDataMap); + $this->assertEquals($channel1ClientsCount, $channelDataMap[$channels[0]]->getOccupancy()); + $this->assertEquals($testLimit, count($channelDataMap[$channels[0]]->getOccupants())); + + // Verify channel2 data + $this->assertArrayHasKey($channels[1], $channelDataMap); + $this->assertEquals($channel2ClientsCount, $channelDataMap[$channels[1]]->getOccupancy()); + $this->assertEquals($testLimit, count($channelDataMap[$channels[1]]->getOccupants())); + } finally { + $this->cleanupBackgroundClients(); + } + } + + /** + * Test pagination with both limit and offset - verifies correct page windowing + * This test requires real background clients to verify UUIDs don't overlap between pages + * + * @group herenow-pagination + */ + public function testHereNowWithLimitAndOffset(): void + { + $pageSize = 3; + $offset = 3; + $totalClientsCount = 8; + $channel = $this->generateTestChannel(); + + try { + // Subscribe 8 clients in the background + $uuids = $this->subscribeBackgroundClients($channel, $totalClientsCount, 30); + + // Wait for presence to propagate + $this->assertTrue( + $this->waitForOccupancy($this->pubnub, $channel, $totalClientsCount, 15, 1), + "Failed to establish presence for $totalClientsCount clients" + ); + + // Get first page to collect all UUIDs + $firstPageUuids = []; + $firstPage = $this->pubnub->hereNow() + ->channels($channel) + ->includeUuids(true) + ->limit($pageSize) + ->sync(); + + foreach ($firstPage->getChannels()[0]->getOccupants() as $occupant) { + $firstPageUuids[] = $occupant->getUuid(); + } + + // Test both limit and offset together - second page starting from position 3 + $response = $this->pubnub->hereNow() + ->channels($channel) + ->includeUuids(true) + ->limit($pageSize) + ->offset($offset) + ->sync(); + + $this->assertEquals(1, $response->getTotalChannels()); + $this->assertEquals($totalClientsCount, $response->getTotalOccupancy()); + + // Should return 3 occupants (limit=3) starting from offset 3 + $this->assertEquals($pageSize, count($response->getChannels()[0]->getOccupants())); + + // Verify paginated results don't overlap with first page + $secondPageUuids = array_map( + function ($occupant) { + return $occupant->getUuid(); + }, + $response->getChannels()[0]->getOccupants() + ); + + foreach ($secondPageUuids as $uuid) { + $this->assertNotContains( + $uuid, + $firstPageUuids, + "Second page should not contain UUIDs from first page" + ); + } + } finally { + $this->cleanupBackgroundClients(); + } + } + + protected function tearDown(): void + { + // Ensure cleanup happens even if test fails + $this->cleanupBackgroundClients(); + parent::tearDown(); + } +} diff --git a/tests/functional/HereNowTest.php b/tests/functional/HereNowTest.php index 9d31b625..09bc961f 100644 --- a/tests/functional/HereNowTest.php +++ b/tests/functional/HereNowTest.php @@ -30,6 +30,7 @@ public function testHereNow() ); $this->assertEquals([ + 'limit' => '1000', 'pnsdk' => PubNubUtil::urlEncode(PubNub::getSdkFullName()), 'uuid' => $this->pubnub->getConfiguration()->getUuid() ], $this->hereNow->buildParams()); @@ -46,6 +47,7 @@ public function testHereNowGroups() $this->assertEquals([ 'channel-group' => 'gr1', + 'limit' => '1000', 'pnsdk' => PubNubUtil::urlEncode(PubNub::getSdkFullName()), 'uuid' => $this->pubnub->getConfiguration()->getUuid() ], $this->hereNow->buildParams()); @@ -68,6 +70,63 @@ public function testHereNowWithOptions() 'channel-group' => 'gr1', 'state' => '1', 'disable-uuids' => '1', + 'limit' => '1000', + 'pnsdk' => PubNubUtil::urlEncode(PubNub::getSdkFullName()), + 'uuid' => $this->pubnub->getConfiguration()->getUuid() + ], $this->hereNow->buildParams()); + } + + public function testHereNowWithLimit(): void + { + $this->hereNow + ->channels("ch1") + ->limit(50); + + $this->assertEquals( + sprintf(ExposedHereNow::PATH, $this->pubnub->getConfiguration()->getSubscribeKey(), "ch1"), + $this->hereNow->buildPath() + ); + + $this->assertEquals([ + 'limit' => '50', + 'pnsdk' => PubNubUtil::urlEncode(PubNub::getSdkFullName()), + 'uuid' => $this->pubnub->getConfiguration()->getUuid() + ], $this->hereNow->buildParams()); + } + + public function testHereNowWithLimitAndOffset(): void + { + $this->hereNow + ->channels("ch1") + ->limit(50) + ->offset(10); + + $this->assertEquals( + sprintf(ExposedHereNow::PATH, $this->pubnub->getConfiguration()->getSubscribeKey(), "ch1"), + $this->hereNow->buildPath() + ); + + $this->assertEquals([ + 'limit' => '50', + 'offset' => '10', + 'pnsdk' => PubNubUtil::urlEncode(PubNub::getSdkFullName()), + 'uuid' => $this->pubnub->getConfiguration()->getUuid() + ], $this->hereNow->buildParams()); + } + + public function testHereNowWithLimitZero(): void + { + $this->hereNow + ->channels("ch1") + ->limit(0); + + $this->assertEquals( + sprintf(ExposedHereNow::PATH, $this->pubnub->getConfiguration()->getSubscribeKey(), "ch1"), + $this->hereNow->buildPath() + ); + + $this->assertEquals([ + 'limit' => '0', 'pnsdk' => PubNubUtil::urlEncode(PubNub::getSdkFullName()), 'uuid' => $this->pubnub->getConfiguration()->getUuid() ], $this->hereNow->buildParams()); diff --git a/tests/helpers/PresenceCallback.php b/tests/helpers/PresenceCallback.php new file mode 100644 index 00000000..b0ca39d4 --- /dev/null +++ b/tests/helpers/PresenceCallback.php @@ -0,0 +1,68 @@ +startTime = time(); + $this->duration = $duration; + } + + /** + * @param PubNub $pubnub + * @param PNStatus $status + * @return void + */ + public function status($pubnub, $status): void + { + // Exit if connected and duration exceeded + if ($status->getCategory() === PNStatusCategory::PNConnectedCategory) { + fwrite(STDOUT, "Connected: {$pubnub->getConfiguration()->getUuid()}\n"); + flush(); + } + + // Check if we should exit + if (time() - $this->startTime > $this->duration) { + throw new PubNubUnsubscribeException(); + } + } + + /** + * @param PubNub $pubnub + * @param PNMessageResult $message + * @return void + */ + public function message($pubnub, $message): void + { + // Check if we should exit + if (time() - $this->startTime > $this->duration) { + throw new PubNubUnsubscribeException(); + } + } + + /** + * @param PubNub $pubnub + * @param PNPresenceEventResult $presence + * @return void + */ + public function presence($pubnub, $presence): void + { + // Do nothing + } +} diff --git a/tests/helpers/PresenceSubscriber.php b/tests/helpers/PresenceSubscriber.php new file mode 100644 index 00000000..3fe99915 --- /dev/null +++ b/tests/helpers/PresenceSubscriber.php @@ -0,0 +1,51 @@ + [duration_seconds]\n"); + exit(1); +} + +$channel = $argv[1]; +$uuid = $argv[2]; +$duration = isset($argv[3]) ? (int)$argv[3] : 30; // Default 30 seconds + +$config = new PNConfiguration(); +$config->setSubscribeKey(getenv("SUBSCRIBE_KEY")); +$config->setPublishKey(getenv("PUBLISH_KEY")); +$config->setUuid($uuid); + +$pubnub = new PubNub($config); + +try { + fwrite(STDOUT, "Starting subscription for $uuid on channel $channel\n"); + flush(); + + $pubnub->addListener(new PresenceCallback($duration)); + + fwrite(STDOUT, "Listener added, calling subscribe...\n"); + flush(); + + $pubnub->subscribe()->channels($channel)->withPresence()->execute(); + + fwrite(STDOUT, "Subscribe returned (should not reach here normally)\n"); + flush(); +} catch (PubNubUnsubscribeException $e) { + fwrite(STDOUT, "Unsubscribed: $uuid\n"); + exit(0); +} catch (Exception $e) { + fwrite(STDERR, "Error: " . $e->getMessage() . "\n"); + fwrite(STDERR, "Stack trace: " . $e->getTraceAsString() . "\n"); + exit(1); +} diff --git a/tests/helpers/PresenceTestHelper.php b/tests/helpers/PresenceTestHelper.php new file mode 100644 index 00000000..099f514c --- /dev/null +++ b/tests/helpers/PresenceTestHelper.php @@ -0,0 +1,182 @@ + */ + private array $backgroundProcesses = []; + + /** + * Subscribe multiple clients to a channel in the background + * + * @param string $channel Channel to subscribe to + * @param int $clientCount Number of clients to subscribe + * @param int $duration How long to keep subscribed (seconds) + * @param string $uuidPrefix Prefix for generated UUIDs + * @return array Array of UUIDs that were subscribed + */ + protected function subscribeBackgroundClients( + string $channel, + int $clientCount, + int $duration = 30, + string $uuidPrefix = "test-user-" + ): array { + $uuids = []; + $scriptPath = __DIR__ . '/PresenceSubscriber.php'; + + for ($i = 0; $i < $clientCount; $i++) { + $uuid = $uuidPrefix . uniqid() . "-" . $i; + $uuids[] = $uuid; + + $descriptorspec = [ + 0 => ["pipe", "r"], // stdin + 1 => ["pipe", "w"], // stdout + 2 => ["pipe", "w"] // stderr + ]; + + // Use PHP_BINARY to get full path to PHP executable + // This ensures the command works even when PATH is not set in child process + $cmd = sprintf( + '%s %s %s %s %d', + escapeshellarg(PHP_BINARY), + escapeshellarg($scriptPath), + escapeshellarg($channel), + escapeshellarg($uuid), + $duration + ); + + // Build environment: start with current OS environment, then ensure PubNub keys are set + // bootstrap.php calls putenv() for .env.dev variables, making them available to getenv() + $env = getenv(); // Get all current OS environment variables + $env['PUBLISH_KEY'] = getenv('PUBLISH_KEY') ?: ''; + $env['SUBSCRIBE_KEY'] = getenv('SUBSCRIBE_KEY') ?: ''; + $env['SECRET_KEY'] = getenv('SECRET_KEY') ?: ''; + + $process = proc_open($cmd, $descriptorspec, $pipes, null, $env); + + if (is_resource($process)) { + // Store process info for cleanup + $this->backgroundProcesses[] = [ + 'process' => $process, + 'pipes' => $pipes, + 'uuid' => $uuid + ]; + + // Make pipes non-blocking + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + } + } + + // Wait for background processes to start and establish subscriptions + // Similar to Kotlin's Thread.sleep(2000) approach + sleep(2); + + return $uuids; + } + + /** + * Wait for background clients to connect + * + * @param int $seconds Seconds to wait + */ + protected function waitForPresence(int $seconds = 2): void + { + sleep($seconds); + } + + /** + * Wait for presence to propagate by checking hereNow + * + * @param \PubNub\PubNub $pubnub PubNub instance + * @param string|array $channels Channel(s) to check + * @param int $expectedOccupancy Expected occupancy count + * @param int $maxAttempts Maximum retry attempts + * @param int $delaySeconds Delay between attempts + * @return bool True if expected occupancy reached + */ + protected function waitForOccupancy( + $pubnub, + string|array $channels, + int $expectedOccupancy, + int $maxAttempts = 10, + int $delaySeconds = 1 + ): bool { + $channels = is_array($channels) ? $channels : [$channels]; + + for ($attempt = 0; $attempt < $maxAttempts; $attempt++) { + try { + $result = $pubnub->hereNow() + ->channels($channels) + ->includeUuids(false) + ->sync(); + + $totalOccupancy = $result->getTotalOccupancy(); + + if ($totalOccupancy >= $expectedOccupancy) { + return true; + } + } catch (\Exception $e) { + // Ignore errors during polling + } + + sleep($delaySeconds); + } + + return false; + } + + /** + * Clean up all background processes + */ + protected function cleanupBackgroundClients(): void + { + foreach ($this->backgroundProcesses as $processInfo) { + if (is_resource($processInfo['process'])) { + // Close pipes + foreach ($processInfo['pipes'] as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + + // Terminate process + proc_terminate($processInfo['process']); + proc_close($processInfo['process']); + } + } + + $this->backgroundProcesses = []; + } + + /** + * Generate a unique channel name for testing + * + * @param string $prefix Prefix for the channel name + * @return string Unique channel name + */ + protected function generateTestChannel(string $prefix = "test-channel-"): string + { + return $prefix . uniqid() . '-' . mt_rand(1000, 9999); + } + + /** + * Generate multiple unique channel names + * + * @param int $count Number of channels to generate + * @param string $prefix Prefix for channel names + * @return array Array of unique channel names + */ + protected function generateTestChannels(int $count, string $prefix = "test-channel-"): array + { + $channels = []; + for ($i = 0; $i < $count; $i++) { + $channels[] = $this->generateTestChannel($prefix); + } + return $channels; + } +} diff --git a/tests/integrational/HereNowTest.php b/tests/integrational/HereNowTest.php index 79bdf48f..5b34a355 100644 --- a/tests/integrational/HereNowTest.php +++ b/tests/integrational/HereNowTest.php @@ -20,6 +20,7 @@ public function testMultipleChannelState() $hereNow->stubFor("/v2/presence/sub-key/demo/channel/ch1,ch2") ->withQuery([ 'state' => '1', + 'limit' => '1000', 'pnsdk' => $this->encodedSdkName, 'uuid' => $this->pubnub_demo->getConfiguration()->getUuid(), ]) @@ -57,6 +58,7 @@ public function testMultipleChannel() $hereNow->stubFor("/v2/presence/sub-key/demo/channel/ch1,ch2") ->withQuery([ 'state' => '1', + 'limit' => '1000', 'pnsdk' => $this->encodedSdkName, 'uuid' => $this->pubnub_demo->getConfiguration()->getUuid(), ]) @@ -93,6 +95,7 @@ public function testMultipleChannelWithoutState() $hereNow = new HereNowExposed($this->pubnub_demo); $hereNow->stubFor("/v2/presence/sub-key/demo/channel/game1,game2") ->withQuery([ + 'limit' => '1000', 'pnsdk' => $this->encodedSdkName, 'uuid' => $this->pubnub_demo->getConfiguration()->getUuid(), ]) @@ -125,6 +128,7 @@ public function testMultipleChannelWithoutStateUUIDs() $hereNow->stubFor("/v2/presence/sub-key/demo/channel/game1,game2") ->withQuery([ 'disable-uuids' => '1', + 'limit' => '1000', 'pnsdk' => $this->encodedSdkName, 'uuid' => $this->pubnub_demo->getConfiguration()->getUuid(), ]) @@ -151,6 +155,7 @@ public function testSingularChannelWithoutStateUUIDs() $hereNow->stubFor("/v2/presence/sub-key/demo/channel/game1") ->withQuery([ 'disable-uuids' => '1', + 'limit' => '1000', 'pnsdk' => $this->encodedSdkName, 'uuid' => $this->pubnub_demo->getConfiguration()->getUuid(), ]) @@ -171,6 +176,7 @@ public function testSingularChannelWithoutState() $hereNow = new HereNowExposed($this->pubnub_demo); $hereNow->stubFor("/v2/presence/sub-key/demo/channel/game1") ->withQuery([ + 'limit' => '1000', 'pnsdk' => $this->encodedSdkName, 'uuid' => $this->pubnub_demo->getConfiguration()->getUuid(), ]) @@ -201,6 +207,7 @@ public function testSingularChannel() $hereNow->stubFor("/v2/presence/sub-key/demo/channel/game1") ->withQuery([ 'state' => '1', + 'limit' => '1000', 'pnsdk' => $this->encodedSdkName, 'uuid' => $this->pubnub_demo->getConfiguration()->getUuid(), ]) @@ -232,6 +239,7 @@ public function testSingularChannelAndGroup() ->withQuery([ 'channel-group' => 'grp1', 'state' => '1', + 'limit' => '1000', 'pnsdk' => $this->encodedSdkName, 'uuid' => $this->pubnub_demo->getConfiguration()->getUuid(), ]) @@ -254,6 +262,7 @@ public function testIsAuthRequiredSuccess() $hereNow->stubFor("/v2/presence/sub-key/demo/channel/ch1,ch2") ->withQuery([ 'state' => '1', + 'limit' => '1000', 'pnsdk' => $this->encodedSdkName, 'uuid' => $this->pubnub_demo->getConfiguration()->getUuid(), 'auth' => 'myKey' @@ -323,16 +332,148 @@ public function testEmptySubKey() $hereNow->channels(["ch1", "ch2"])->includeState(true)->sync(); } - public function testSuperCallTest() + /** + * @group herenow + * @group herenow-integrational + * @group herenow-pagination + */ + public function testHereNowWithLimit(): void { - $this->expectNotToPerformAssertions(); - // Not valid - // ,~/ - $characters = "-._:?#[]@!$&'()*+;=`|"; + $hereNow = new HereNowExposed($this->pubnub_demo); + $hereNow->stubFor("/v2/presence/sub-key/demo/channel/test-channel") + ->withQuery([ + 'limit' => '3', + 'pnsdk' => $this->encodedSdkName, + 'uuid' => $this->pubnub_demo->getConfiguration()->getUuid(), + ]) + ->setResponseBody( + "{\"status\":200,\"message\":\"OK\",\"payload\":{\"total_occupancy\":6," + . "\"total_channels\":1,\"channels\":{\"test-channel\":{\"occupancy\":6," + . "\"uuids\":[{\"uuid\":\"user1\"},{\"uuid\":\"user2\"},{\"uuid\":\"user3\"}]}}}," + . "\"service\":\"Presence\"}" + ); + + $response = $hereNow->channels("test-channel")->limit(3)->sync(); + + $this->assertEquals(1, $response->getTotalChannels()); + $this->assertEquals(6, $response->getTotalOccupancy()); + $this->assertEquals(1, count($response->getChannels())); + $this->assertEquals(6, $response->getChannels()[0]->getOccupancy()); + + // With limit=3, should return only 3 occupants even though 6 are present + $this->assertEquals(3, count($response->getChannels()[0]->getOccupants())); + } + + /** + * @group herenow + * @group herenow-integrational + * @group herenow-pagination + */ + public function testHereNowWithOffset(): void + { + $hereNow = new HereNowExposed($this->pubnub_demo); + $hereNow->stubFor("/v2/presence/sub-key/demo/channel/test-channel") + ->withQuery([ + 'offset' => '2', + 'limit' => '1000', + 'pnsdk' => $this->encodedSdkName, + 'uuid' => $this->pubnub_demo->getConfiguration()->getUuid(), + ]) + ->setResponseBody( + "{\"status\":200,\"message\":\"OK\",\"payload\":{\"total_occupancy\":5," + . "\"total_channels\":1,\"channels\":{\"test-channel\":{\"occupancy\":5," + . "\"uuids\":[{\"uuid\":\"user3\"},{\"uuid\":\"user4\"},{\"uuid\":\"user5\"}]}}}," + . "\"service\":\"Presence\"}" + ); + + $response = $hereNow->channels("test-channel")->offset(2)->sync(); + + $this->assertEquals(1, $response->getTotalChannels()); + $this->assertEquals(5, $response->getTotalOccupancy()); + + // With offset=2, we should get remaining occupants (5 - 2 = 3) + $returnedOccupants = $response->getChannels()[0]->getOccupants(); + $this->assertEquals(3, count($returnedOccupants)); + + // Verify UUIDs are from the offset portion + $this->assertEquals("user3", $returnedOccupants[0]->getUuid()); + $this->assertEquals("user4", $returnedOccupants[1]->getUuid()); + $this->assertEquals("user5", $returnedOccupants[2]->getUuid()); + } + + /** + * @group herenow + * @group herenow-integrational + * @group herenow-pagination + */ + public function testHereNowWithLimitZero(): void + { + $hereNow = new HereNowExposed($this->pubnub_demo); + $hereNow->stubFor("/v2/presence/sub-key/demo/channel/test-channel") + ->withQuery([ + 'limit' => '0', + 'pnsdk' => $this->encodedSdkName, + 'uuid' => $this->pubnub_demo->getConfiguration()->getUuid(), + ]) + ->setResponseBody("{\"status\":200,\"message\":\"OK\",\"payload\":{\"total_occupancy\":5,\"total_channels\"" + . ":1,\"channels\":{\"test-channel\":{\"occupancy\":5,\"uuids\":[]}}},\"service\":\"Presence\"}"); - $this->pubnub_pam->hereNow() - ->channels($characters) - ->sync(); + $response = $hereNow->channels("test-channel")->limit(0)->sync(); + + $this->assertEquals(1, $response->getTotalChannels()); + $this->assertEquals(5, $response->getTotalOccupancy()); + $this->assertEquals(5, $response->getChannels()[0]->getOccupancy()); + + // With limit=0, occupants should be empty array (no occupant details returned) + $this->assertIsArray($response->getChannels()[0]->getOccupants()); + $this->assertEmpty($response->getChannels()[0]->getOccupants()); + } + + /** + * @group herenow + * @group herenow-integrational + * @group herenow-pagination + */ + public function testHereNowMultipleChannelsWithLimitZero(): void + { + $hereNow = new HereNowExposed($this->pubnub_demo); + $hereNow->stubFor("/v2/presence/sub-key/demo/channel/channel1,channel2") + ->withQuery([ + 'limit' => '0', + 'pnsdk' => $this->encodedSdkName, + 'uuid' => $this->pubnub_demo->getConfiguration()->getUuid(), + ]) + ->setResponseBody( + "{\"status\":200,\"message\":\"OK\",\"payload\":{\"total_occupancy\":8," + . "\"total_channels\":2,\"channels\":{\"channel1\":{\"occupancy\":5}," + . "\"channel2\":{\"occupancy\":3}}},\"service\":\"Presence\"}" + ); + + $response = $hereNow->channels(["channel1", "channel2"])->limit(0)->sync(); + + $this->assertEquals(2, $response->getTotalChannels()); + $this->assertEquals(8, $response->getTotalOccupancy()); + + // Find channels in response (order may vary) + $channelDataMap = []; + foreach ($response->getChannels() as $channelData) { + $channelDataMap[$channelData->getChannelName()] = $channelData; + } + + // Verify channel1 data - occupancy present, occupants null (different from single channel!) + $this->assertArrayHasKey("channel1", $channelDataMap); + $this->assertEquals(5, $channelDataMap["channel1"]->getOccupancy()); + $this->assertNull($channelDataMap["channel1"]->getOccupants()); + + // Verify channel2 data - occupancy present, occupants null (different from single channel!) + $this->assertArrayHasKey("channel2", $channelDataMap); + $this->assertEquals(3, $channelDataMap["channel2"]->getOccupancy()); + $this->assertNull($channelDataMap["channel2"]->getOccupants()); + } + + protected function tearDown(): void + { + parent::tearDown(); } }