├── .editorconfig ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .styleci.yml ├── LICENSE ├── README.md ├── VERSION ├── composer.json ├── phpunit.xml.dist ├── src ├── Connectors │ └── PubSubConnector.php ├── Jobs │ └── PubSubJob.php ├── PubSubQueue.php ├── PubSubQueueServiceProvider.php └── Traits │ └── HasOrderingKey.php └── tests └── Unit ├── Connectors └── PubSubConnectorTests.php ├── Jobs └── PubSubJobTests.php └── PubSubQueueTests.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_size = 4 6 | indent_style = space 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | paths-ignore: ['*.md'] 6 | pull_request: 7 | branches: [master] 8 | paths-ignore: ['*.md'] 9 | 10 | jobs: 11 | test: 12 | name: Test (PHP ${{ matrix.php }}) 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | php: [8.1, 8.2, 8.3] 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Setup PHP 20 | uses: shivammathur/setup-php@v2 21 | with: 22 | php-version: ${{ matrix.php }} 23 | extensions: json 24 | coverage: none 25 | - name: Install composer dependencies 26 | run: composer install --prefer-dist --no-interaction --no-progress 27 | - name: Run the test suite 28 | run: vendor/bin/phpunit 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | composer.lock 3 | /.phpunit.cache 4 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: laravel 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Kendryck Abdou 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel PubSub Queue 2 | 3 | ![Build Status](https://github.com/kainxspirits/laravel-pubsub-queue/actions/workflows/main.yml/badge.svg) 4 | [![StyleCI](https://styleci.io/repos/131718560/shield)](https://styleci.io/repos/131718560) 5 | 6 | This package is a Laravel queue driver that uses the [Google PubSub](https://github.com/GoogleCloudPlatform/google-cloud-php-pubsub) service. 7 | 8 | ## Installation 9 | 10 | You can easily install this package with [Composer](https://getcomposer.org) by running this command : 11 | 12 | ```bash 13 | composer require kainxspirits/laravel-pubsub-queue 14 | ``` 15 | 16 | If you disabled package discovery, you can still manually register this package by adding the following line to the providers of your `config/app.php` file : 17 | 18 | ```php 19 | Kainxspirits\PubSubQueue\PubSubQueueServiceProvider::class, 20 | ``` 21 | 22 | ## Configuration 23 | 24 | Add a `pubsub` connection to your `config/queue.php` file. From there, you can use any configuration values from the original pubsub client. Just make sure to use snake_case for the keys name. 25 | 26 | You can check [Google Cloud PubSub client](http://googleapis.github.io/google-cloud-php/#/docs/cloud-pubsub/master/pubsub/pubsubclient?method=__construct) for more details about the different options. 27 | 28 | ```php 29 | 'pubsub' => [ 30 | 'driver' => 'pubsub', 31 | 'queue' => env('PUBSUB_QUEUE', 'default'), 32 | 'queue_prefix' => env('PUBSUB_QUEUE_PREFIX', ''), 33 | 'project_id' => env('PUBSUB_PROJECT_ID', 'your-project-id'), 34 | 'retries' => 3, 35 | 'request_timeout' => 60, 36 | 'subscriber' => 'subscriber-name', 37 | 'create_topics' => true, 38 | 'create_subscriptions' => true, 39 | ], 40 | ``` 41 | 42 | ## Avoiding Administrator Operations Limits 43 | 44 | To avoid limit issues, change the `create_topics` and `create_subscriptions` flags to false. 45 | 46 | ## Testing 47 | 48 | You can run the tests with : 49 | 50 | ```bash 51 | vendor/bin/phpunit 52 | ``` 53 | 54 | ## License 55 | 56 | This project is licensed under the terms of the MIT license. See [License File](LICENSE) for more information. 57 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.10.0 2 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kainxspirits/laravel-pubsub-queue", 3 | "description": "Queue driver for Google Cloud Pub/Sub.", 4 | "keywords": [ 5 | "kainxspirits", 6 | "laravel", 7 | "queue", 8 | "gcp", 9 | "google", 10 | "pubsub" 11 | ], 12 | "license": "MIT", 13 | "type": "library", 14 | "authors": [ 15 | { 16 | "name": "Kendryck", 17 | "email": "kainxspirits@users.noreply.github.com" 18 | } 19 | ], 20 | "require": { 21 | "php": ">=8.1", 22 | "ext-json": "*", 23 | "illuminate/queue": "5.7.* | 5.8.* | ^6.0 | ^7.0 | ^8.0 | ^9.0| ^10.0 | ^11.0 | ^12.0", 24 | "illuminate/support": "5.7.* | 5.8.* | ^6.0 | ^7.0 | ^8.0 | ^9.0| ^10.0 | ^11.0 | ^12.0", 25 | "google/cloud-pubsub": "^1.1", 26 | "ramsey/uuid": "^2.0|^3.0|^4.0" 27 | }, 28 | "require-dev": { 29 | "phpunit/phpunit": "^10.0" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "Kainxspirits\\PubSubQueue\\": "src/" 34 | } 35 | }, 36 | "autoload-dev": { 37 | "psr-4": { 38 | "Kainxspirits\\PubSubQueue\\Tests\\": "tests/" 39 | } 40 | }, 41 | "scripts": { 42 | "test": "vendor/bin/phpunit" 43 | }, 44 | "extra": { 45 | "laravel": { 46 | "providers": [ 47 | "Kainxspirits\\PubSubQueue\\PubSubQueueServiceProvider" 48 | ] 49 | } 50 | }, 51 | "minimum-stability": "stable" 52 | } 53 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | ./tests/ 15 | 16 | 17 | 18 | 19 | src/ 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/Connectors/PubSubConnector.php: -------------------------------------------------------------------------------- 1 | transformConfig($config); 28 | 29 | return new PubSubQueue( 30 | new PubSubClient($gcp_config), 31 | $config['queue'] ?? $this->default_queue, 32 | $config['subscriber'] ?? 'subscriber', 33 | $config['create_topics'] ?? true, 34 | $config['create_subscriptions'] ?? true, 35 | $config['queue_prefix'] ?? '' 36 | ); 37 | } 38 | 39 | /** 40 | * Transform the config to key => value array. 41 | * 42 | * @param array $config 43 | * @return array 44 | */ 45 | protected function transformConfig($config) 46 | { 47 | return array_reduce(array_map([$this, 'transformConfigKeys'], $config, array_keys($config)), function ($carry, $item) { 48 | $carry[$item[0]] = $item[1]; 49 | 50 | return $carry; 51 | }, []); 52 | } 53 | 54 | /** 55 | * Transform the keys of config to camelCase. 56 | * 57 | * @param string $item 58 | * @param string $key 59 | * @return array 60 | */ 61 | protected function transformConfigKeys($item, $key) 62 | { 63 | return [Str::camel($key), $item]; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Jobs/PubSubJob.php: -------------------------------------------------------------------------------- 1 | pubsub = $pubsub; 46 | $this->job = $job; 47 | $this->queue = $queue; 48 | $this->container = $container; 49 | $this->connectionName = $connectionName; 50 | 51 | $this->decoded = $this->payload(); 52 | } 53 | 54 | /** 55 | * Get the job identifier. 56 | * 57 | * @return string 58 | */ 59 | public function getJobId() 60 | { 61 | return $this->decoded['id'] ?? null; 62 | } 63 | 64 | /** 65 | * Get the raw body of the job. 66 | * 67 | * @return string 68 | */ 69 | public function getRawBody() 70 | { 71 | return base64_decode($this->job->data()); 72 | } 73 | 74 | /** 75 | * Get the number of times the job has been attempted. 76 | * 77 | * @return int 78 | */ 79 | public function attempts() 80 | { 81 | return ((int) $this->job->attribute('attempts') ?? 0) + 1; 82 | } 83 | 84 | /** 85 | * Release the job back into the queue. 86 | * 87 | * @param int $delay 88 | * @return void 89 | */ 90 | public function release($delay = 0) 91 | { 92 | parent::release($delay); 93 | 94 | $attempts = $this->attempts(); 95 | $this->pubsub->republish( 96 | $this->job, 97 | $this->queue, 98 | ['attempts' => (string) $attempts], 99 | $delay 100 | ); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/PubSubQueue.php: -------------------------------------------------------------------------------- 1 | pubsub = $pubsub; 66 | $this->default = $default; 67 | $this->subscriber = $subscriber; 68 | $this->topicAutoCreation = $topicAutoCreation; 69 | $this->subscriptionAutoCreation = $subscriptionAutoCreation; 70 | $this->queuePrefix = $queuePrefix; 71 | } 72 | 73 | /** 74 | * Get the size of the queue. 75 | * PubSubClient have no method to retrieve the size of the queue. 76 | * To be updated if the API allow to get that data. 77 | * 78 | * @param string $queue 79 | * @return int 80 | */ 81 | public function size($queue = null) 82 | { 83 | return 0; 84 | } 85 | 86 | /** 87 | * Push a new job onto the queue. 88 | * 89 | * @param string|object $job 90 | * @param mixed $data 91 | * @param string $queue 92 | * @return mixed 93 | */ 94 | public function push($job, $data = '', $queue = null) 95 | { 96 | $options = []; 97 | 98 | if (isset($job->orderingKey)) { 99 | $options['orderingKey'] = $job->orderingKey; 100 | } 101 | 102 | return $this->enqueueUsing( 103 | $job, 104 | $this->createPayload($job, $this->getQueue($queue), $data), 105 | $queue, 106 | null, 107 | function ($payload, $queue) use ($options) { 108 | return $this->pushRaw($payload, $queue, $options); 109 | } 110 | ); 111 | } 112 | 113 | /** 114 | * Push a raw payload onto the queue. 115 | * 116 | * @param string $payload 117 | * @param string $queue 118 | * @param array $options 119 | * @return array 120 | */ 121 | public function pushRaw($payload, $queue = null, array $options = []) 122 | { 123 | $topic = $this->getTopic($queue, $this->topicAutoCreation); 124 | 125 | $this->subscribeToTopic($topic); 126 | 127 | $publish = ['data' => base64_encode($payload)]; 128 | 129 | if (! empty($options)) { 130 | $publish['attributes'] = $this->validateMessageAttributes($options); 131 | 132 | if (isset($options['orderingKey'])) { 133 | $publish['orderingKey'] = $options['orderingKey']; 134 | } 135 | } 136 | $topic->publish($publish); 137 | 138 | $decoded_payload = json_decode($payload, true); 139 | 140 | return $decoded_payload['id']; 141 | } 142 | 143 | /** 144 | * Push a new job onto the queue after a delay. 145 | * 146 | * @param \DateTimeInterface|\DateInterval|int $delay 147 | * @param string|object $job 148 | * @param mixed $data 149 | * @param string $queue 150 | * @return mixed 151 | */ 152 | public function later($delay, $job, $data = '', $queue = null) 153 | { 154 | return $this->pushRaw( 155 | $this->createPayload($job, $this->getQueue($queue), $data), 156 | $queue, 157 | ['available_at' => (string) $this->availableAt($delay)] 158 | ); 159 | } 160 | 161 | /** 162 | * Pop the next job off of the queue. 163 | * 164 | * @param string $queue 165 | * @return \Illuminate\Contracts\Queue\Job|null 166 | */ 167 | public function pop($queue = null) 168 | { 169 | $topic = $this->getTopic($this->getQueue($queue)); 170 | 171 | if ($this->topicAutoCreation && ! $topic->exists()) { 172 | return; 173 | } 174 | 175 | $subscription = $topic->subscription($this->getSubscriberName()); 176 | $messages = $subscription->pull([ 177 | 'returnImmediately' => true, 178 | 'maxMessages' => 1, 179 | ]); 180 | 181 | if (empty($messages) || count($messages) < 1) { 182 | return; 183 | } 184 | 185 | $available_at = $messages[0]->attribute('available_at'); 186 | if ($available_at && $available_at > time()) { 187 | return; 188 | } 189 | 190 | $this->acknowledge($messages[0], $queue); 191 | 192 | return new PubSubJob( 193 | $this->container, 194 | $this, 195 | $messages[0], 196 | $this->connectionName, 197 | $this->getQueue($queue) 198 | ); 199 | } 200 | 201 | /** 202 | * Push an array of jobs onto the queue. 203 | * 204 | * @param array $jobs 205 | * @param mixed $data 206 | * @param string $queue 207 | * @return mixed 208 | */ 209 | public function bulk($jobs, $data = '', $queue = null) 210 | { 211 | $payloads = []; 212 | 213 | foreach ((array) $jobs as $job) { 214 | $payload = $this->createPayload($job, $this->getQueue($queue), $data); 215 | $payloads[] = ['data' => base64_encode($payload)] + (isset($job->orderingKey) ? ['orderingKey' => $job->orderingKey] : []); 216 | } 217 | 218 | $topic = $this->getTopic($this->getQueue($queue), $this->topicAutoCreation); 219 | 220 | $this->subscribeToTopic($topic); 221 | 222 | return $topic->publishBatch($payloads); 223 | } 224 | 225 | /** 226 | * Acknowledge a message. 227 | * 228 | * @param \Google\Cloud\PubSub\Message $message 229 | * @param string $queue 230 | */ 231 | public function acknowledge(Message $message, $queue = null) 232 | { 233 | $subscription = $this->getTopic($this->getQueue($queue))->subscription($this->getSubscriberName()); 234 | $subscription->acknowledge($message); 235 | } 236 | 237 | /** 238 | * Republish a message onto the queue. 239 | * 240 | * @param \Google\Cloud\PubSub\Message $message 241 | * @param string $queue 242 | * @return mixed 243 | */ 244 | public function republish(Message $message, $queue = null, $options = [], $delay = 0) 245 | { 246 | $topic = $this->getTopic($this->getQueue($queue)); 247 | 248 | $options = array_merge([ 249 | 'available_at' => (string) $this->availableAt($delay), 250 | ], $this->validateMessageAttributes($options)); 251 | 252 | return $topic->publish([ 253 | 'data' => $message->data(), 254 | 'attributes' => $options, 255 | ]); 256 | } 257 | 258 | /** 259 | * Create a payload array from the given job and data. 260 | * 261 | * @param mixed $job 262 | * @param string $queue 263 | * @param mixed $data 264 | * @return array 265 | */ 266 | protected function createPayloadArray($job, $queue, $data = '') 267 | { 268 | return array_merge(parent::createPayloadArray($job, $this->getQueue($queue), $data), [ 269 | 'id' => $this->getRandomId(), 270 | ]); 271 | } 272 | 273 | /** 274 | * Check if the attributes array only contains key-values 275 | * pairs made of strings. 276 | * 277 | * @param array $attributes 278 | * @return array 279 | * 280 | * @throws \UnexpectedValueException 281 | */ 282 | private function validateMessageAttributes($attributes): array 283 | { 284 | $attributes_values = array_filter($attributes, 'is_string'); 285 | 286 | if (count($attributes_values) !== count($attributes)) { 287 | throw new \UnexpectedValueException('PubSubMessage attributes only accept key-value pairs and all values must be string.'); 288 | } 289 | 290 | $attributes_keys = array_filter(array_keys($attributes), 'is_string'); 291 | 292 | if (count($attributes_keys) !== count(array_keys($attributes))) { 293 | throw new \UnexpectedValueException('PubSubMessage attributes only accept key-value pairs and all keys must be string.'); 294 | } 295 | 296 | return $attributes; 297 | } 298 | 299 | /** 300 | * Get the current topic. 301 | * 302 | * @param string $queue 303 | * @param string $create 304 | * @return \Google\Cloud\PubSub\Topic 305 | */ 306 | public function getTopic($queue, $create = false) 307 | { 308 | $queue = $this->getQueue($queue); 309 | $topic = $this->pubsub->topic($queue); 310 | 311 | // don't check topic if automatic creation is not required, to avoid additional administrator operations calls 312 | if ($create && ! $topic->exists()) { 313 | $topic->create(); 314 | } 315 | 316 | return $topic; 317 | } 318 | 319 | /** 320 | * Create a new subscription to a topic. 321 | * 322 | * @param \Google\Cloud\PubSub\Topic $topic 323 | * @return \Google\Cloud\PubSub\Subscription 324 | */ 325 | public function subscribeToTopic(Topic $topic) 326 | { 327 | $subscription = $topic->subscription($this->getSubscriberName()); 328 | 329 | // don't check subscription if automatic creation is not required, to avoid additional administrator operations calls 330 | if ($this->subscriptionAutoCreation && ! $subscription->exists()) { 331 | $subscription = $topic->subscribe($this->getSubscriberName()); 332 | } 333 | 334 | return $subscription; 335 | } 336 | 337 | /** 338 | * Get subscriber name. 339 | * 340 | * @param \Google\Cloud\PubSub\Topic $topic 341 | * @return string 342 | */ 343 | public function getSubscriberName() 344 | { 345 | return $this->subscriber; 346 | } 347 | 348 | /** 349 | * Get the PubSub instance. 350 | * 351 | * @return \Google\Cloud\PubSub\PubSubClient 352 | */ 353 | public function getPubSub() 354 | { 355 | return $this->pubsub; 356 | } 357 | 358 | /** 359 | * Get the queue or return the default. 360 | * 361 | * @param string|null $queue 362 | * @return string 363 | */ 364 | public function getQueue($queue) 365 | { 366 | $queue = $queue ?: $this->default; 367 | 368 | if (! $this->queuePrefix || Str::startsWith($queue, $this->queuePrefix)) { 369 | return $queue; 370 | } 371 | 372 | return $this->queuePrefix.$queue; 373 | } 374 | 375 | /** 376 | * Get a random ID string. 377 | * 378 | * @return string 379 | */ 380 | protected function getRandomId() 381 | { 382 | return Str::random(32); 383 | } 384 | } 385 | -------------------------------------------------------------------------------- /src/PubSubQueueServiceProvider.php: -------------------------------------------------------------------------------- 1 | app['queue']->addConnector('pubsub', function () { 18 | return new PubSubConnector; 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Traits/HasOrderingKey.php: -------------------------------------------------------------------------------- 1 | orderingKey = $orderingKey; 12 | 13 | return $this; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/Unit/Connectors/PubSubConnectorTests.php: -------------------------------------------------------------------------------- 1 | assertTrue($reflection->implementsInterface(ConnectorInterface::class)); 18 | } 19 | 20 | public function testConnectReturnsPubSubQueueInstance(): void 21 | { 22 | $connector = new PubSubConnector; 23 | $config = $this->createFakeConfig(); 24 | $queue = $connector->connect($config); 25 | 26 | $this->assertTrue($queue instanceof PubSubQueue); 27 | $this->assertEquals($queue->getSubscriberName(), 'test-subscriber'); 28 | } 29 | 30 | public function testQueuePrefixAdded(): void 31 | { 32 | $connector = new PubSubConnector(); 33 | $config = $this->createFakeConfig() + ['queue_prefix' => 'prefix-']; 34 | $queue = $connector->connect($config); 35 | 36 | $this->assertEquals('prefix-my-queue', $queue->getQueue('my-queue')); 37 | } 38 | 39 | public function testNotQueuePrefixAddedMultipleTimes(): void 40 | { 41 | $connector = new PubSubConnector(); 42 | $config = $this->createFakeConfig() + ['queue_prefix' => 'prefix-']; 43 | $queue = $connector->connect($config); 44 | 45 | $this->assertEquals('prefix-default', $queue->getQueue($queue->getQueue('default'))); 46 | } 47 | 48 | private function createFakeConfig() 49 | { 50 | return [ 51 | 'queue' => 'test', 52 | 'project_id' => 'the-project-id', 53 | 'subscriber' => 'test-subscriber', 54 | 'retries' => 1, 55 | 'request_timeout' => 60, 56 | ]; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/Unit/Jobs/PubSubJobTests.php: -------------------------------------------------------------------------------- 1 | messageId = '1234'; 59 | $this->messageData = json_encode(['id' => $this->messageId, 'foo' => 'bar']); 60 | $this->messageEncodedData = base64_encode($this->messageData); 61 | 62 | $this->container = $this->createMock(Container::class); 63 | $this->queue = $this->createMock(PubSubQueue::class); 64 | $this->client = $this->createMock(PubSubClient::class); 65 | 66 | $this->message = $this->getMockBuilder(Message::class) 67 | ->setConstructorArgs([[], []]) 68 | ->onlyMethods(['data', 'id', 'attributes']) 69 | ->getMock(); 70 | 71 | $this->message->method('data') 72 | ->willReturn($this->messageEncodedData); 73 | 74 | $this->message->method('id') 75 | ->willReturn($this->messageId); 76 | 77 | $this->message->method('attributes') 78 | ->with($this->equalTo('attempts')) 79 | ->willReturn(42); 80 | 81 | $this->job = $this->getMockBuilder(PubSubJob::class) 82 | ->setConstructorArgs([$this->container, $this->queue, $this->message, 'test', 'test']) 83 | ->onlyMethods([]) 84 | ->getMock(); 85 | } 86 | 87 | public function testImplementsJobInterface(): void 88 | { 89 | $reflection = new ReflectionClass(PubSubJob::class); 90 | $this->assertTrue($reflection->implementsInterface(JobContract::class)); 91 | } 92 | 93 | public function testGetJobId(): void 94 | { 95 | $this->assertEquals($this->job->getJobId(), $this->messageId); 96 | } 97 | 98 | public function testGetRawBody(): void 99 | { 100 | $this->assertEquals($this->job->getRawBody(), $this->messageData); 101 | } 102 | 103 | public function testDeleteMethodSetDeletedProperty(): void 104 | { 105 | $this->job->delete(); 106 | $this->assertTrue($this->job->isDeleted()); 107 | } 108 | 109 | public function testAttempts(): void 110 | { 111 | $this->assertTrue(is_int($this->job->attempts())); 112 | } 113 | 114 | public function testReleaseAndPublish(): void 115 | { 116 | $this->queue->expects($this->once()) 117 | ->method('republish') 118 | ->with( 119 | $this->anything(), 120 | $this->anything(), 121 | $this->callback(function ($options) { 122 | if (! is_array($options)) { 123 | return false; 124 | } 125 | 126 | foreach ($options as $key => $option) { 127 | if (! is_string($option) || ! is_string($key)) { 128 | return false; 129 | } 130 | } 131 | 132 | return true; 133 | }) 134 | ); 135 | 136 | $this->job->release(); 137 | } 138 | 139 | public function testReleaseMethodSetReleasedProperty(): void 140 | { 141 | $this->job->release(); 142 | $this->assertTrue($this->job->isReleased()); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /tests/Unit/PubSubQueueTests.php: -------------------------------------------------------------------------------- 1 | expectedResult = 'message-id'; 52 | 53 | $this->topic = $this->createMock(Topic::class); 54 | $this->client = $this->createMock(PubSubClient::class); 55 | $this->subscription = $this->createMock(Subscription::class); 56 | $this->message = $this->createMock(Message::class); 57 | 58 | $this->queue = $this->getMockBuilder(PubSubQueue::class) 59 | ->setConstructorArgs([$this->client, 'default']) 60 | ->onlyMethods([ 61 | 'pushRaw', 62 | 'getTopic', 63 | 'availableAt', 64 | 'subscribeToTopic', 65 | ])->getMock(); 66 | } 67 | 68 | public function testImplementsQueueInterface(): void 69 | { 70 | $reflection = new ReflectionClass(PubSubQueue::class); 71 | $this->assertTrue($reflection->implementsInterface(QueueContract::class)); 72 | } 73 | 74 | public function testPushNewJob(): void 75 | { 76 | $job = 'test'; 77 | $data = ['foo' => 'bar']; 78 | 79 | $this->queue->setContainer(Container::getInstance()); 80 | 81 | $this->queue->expects($this->once()) 82 | ->method('pushRaw') 83 | ->willReturn($this->expectedResult) 84 | ->with($this->callback(function ($payload) use ($job, $data) { 85 | $decoded_payload = json_decode($payload, true); 86 | 87 | return $decoded_payload['data'] === $data && $decoded_payload['job'] === $job; 88 | })); 89 | 90 | $this->assertEquals($this->expectedResult, $this->queue->push('test', $data)); 91 | } 92 | 93 | public function testPushRaw(): void 94 | { 95 | /** @var \PHPUnit\Framework\MockObject\MockObject&PubSubQueue $queue */ 96 | $queue = $this->getMockBuilder(PubSubQueue::class) 97 | ->setConstructorArgs([$this->client, 'default']) 98 | ->onlyMethods(['getTopic', 'subscribeToTopic']) 99 | ->getMock(); 100 | 101 | $payload = json_encode(['id' => $this->expectedResult]); 102 | 103 | $this->topic->method('publish') 104 | ->willReturn($this->expectedResult) 105 | ->with($this->callback(function ($publish) use ($payload) { 106 | $decoded_payload = base64_decode($publish['data']); 107 | 108 | return $decoded_payload === $payload; 109 | })); 110 | 111 | $queue->method('getTopic') 112 | ->willReturn($this->topic); 113 | 114 | $queue->method('subscribeToTopic') 115 | ->willReturn($this->subscription); 116 | 117 | $this->assertEquals($this->expectedResult, $queue->pushRaw($payload)); 118 | } 119 | 120 | public function testPushRawOptionsOnlyAcceptKeyValueStrings(): void 121 | { 122 | $this->expectException(\UnexpectedValueException::class); 123 | 124 | /** @var \PHPUnit\Framework\MockObject\MockObject&PubSubQueue $queue */ 125 | $queue = $this->getMockBuilder(PubSubQueue::class) 126 | ->setConstructorArgs([$this->client, 'default']) 127 | ->onlyMethods(['getTopic', 'subscribeToTopic']) 128 | ->getMock(); 129 | 130 | $this->topic->method('publish') 131 | ->willReturn($this->expectedResult); 132 | 133 | $queue->method('getTopic') 134 | ->willReturn($this->topic); 135 | 136 | $queue->method('subscribeToTopic') 137 | ->willReturn($this->subscription); 138 | 139 | $payload = json_encode(['id' => $this->expectedResult]); 140 | 141 | $options = [ 142 | 'integer' => 42, 143 | 'array' => [ 144 | 'foo' => 'bar', 145 | ], 146 | 1 => 'wrong key', 147 | 'object' => new \stdClass, 148 | ]; 149 | 150 | $queue->pushRaw($payload, '', $options); 151 | } 152 | 153 | public function testLater(): void 154 | { 155 | $job = 'test'; 156 | $delay = 60; 157 | $delay_timestamp = Carbon::now()->addSeconds($delay)->getTimestamp(); 158 | 159 | $this->queue->method('availableAt') 160 | ->willReturn($delay_timestamp); 161 | 162 | $this->queue->expects($this->once()) 163 | ->method('pushRaw') 164 | ->willReturn($this->expectedResult) 165 | ->with( 166 | $this->isType('string'), 167 | $this->anything(), 168 | $this->callback(function ($options) use ($delay_timestamp) { 169 | if (! is_array($options)) { 170 | return false; 171 | } 172 | 173 | foreach ($options as $key => $option) { 174 | if (! is_string($option) || ! is_string($key)) { 175 | return false; 176 | } 177 | } 178 | 179 | if (! isset($options['available_at']) || $options['available_at'] !== (string) $delay_timestamp) { 180 | return false; 181 | } 182 | 183 | return true; 184 | }) 185 | ); 186 | 187 | $this->assertEquals($this->expectedResult, $this->queue->later($delay, $job, ['foo' => 'bar'])); 188 | } 189 | 190 | public function testPopWhenJobsAvailable(): void 191 | { 192 | $this->subscription->expects($this->once()) 193 | ->method('acknowledge'); 194 | 195 | $this->subscription->method('pull') 196 | ->willReturn([$this->message]); 197 | 198 | $this->topic->method('subscription') 199 | ->willReturn($this->subscription); 200 | 201 | $this->topic->method('exists') 202 | ->willReturn(true); 203 | 204 | $this->queue->method('getTopic') 205 | ->willReturn($this->topic); 206 | 207 | $this->message->method('data') 208 | ->willReturn(base64_encode(json_encode(['foo' => 'bar']))); 209 | 210 | $this->queue->setContainer($this->createMock(Container::class)); 211 | 212 | $this->assertTrue($this->queue->pop('test') instanceof PubSubJob); 213 | } 214 | 215 | public function testPopWhenNoJobAvailable(): void 216 | { 217 | $this->subscription->expects($this->exactly(0)) 218 | ->method('acknowledge'); 219 | 220 | $this->subscription->method('pull') 221 | ->willReturn([]); 222 | 223 | $this->topic->method('subscription') 224 | ->willReturn($this->subscription); 225 | 226 | $this->topic->method('exists') 227 | ->willReturn(true); 228 | 229 | $this->queue->method('getTopic') 230 | ->willReturn($this->topic); 231 | 232 | $this->assertTrue(is_null($this->queue->pop('test'))); 233 | } 234 | 235 | public function testPopWhenTopicDoesNotExist(): void 236 | { 237 | $this->queue->method('getTopic') 238 | ->willReturn($this->topic); 239 | 240 | $this->topic->method('exists') 241 | ->willReturn(false); 242 | 243 | $this->assertTrue(is_null($this->queue->pop('test'))); 244 | } 245 | 246 | public function testPopWhenJobDelayed(): void 247 | { 248 | $delay = 60; 249 | $timestamp = Carbon::now()->addSeconds($delay)->getTimestamp(); 250 | 251 | $message = $this->message->method('attribute') 252 | ->willReturn($timestamp); 253 | 254 | $this->subscription->method('pull') 255 | ->willReturn([$this->message]); 256 | 257 | $this->topic->method('subscription') 258 | ->willReturn($this->subscription); 259 | 260 | $this->topic->method('exists') 261 | ->willReturn(true); 262 | 263 | $this->queue->method('getTopic') 264 | ->willReturn($this->topic); 265 | 266 | $this->queue->setContainer($this->createMock(Container::class)); 267 | 268 | $this->assertTrue(is_null($this->queue->pop('test'))); 269 | } 270 | 271 | public function testBulk(): void 272 | { 273 | $jobs = ['test']; 274 | $data = ['foo' => 'bar']; 275 | 276 | $this->topic->expects($this->once()) 277 | ->method('publishBatch') 278 | ->willReturn($this->expectedResult) 279 | ->with($this->callback(function ($payloads) use ($jobs, $data) { 280 | $decoded_payload = json_decode(base64_decode($payloads[0]['data']), true); 281 | 282 | return $decoded_payload['job'] === $jobs[0] && $decoded_payload['data'] === $data; 283 | })); 284 | 285 | $this->queue->method('getTopic') 286 | ->willReturn($this->topic); 287 | 288 | $this->queue->method('subscribeToTopic') 289 | ->willReturn($this->subscription); 290 | 291 | $this->assertEquals($this->expectedResult, $this->queue->bulk($jobs, $data)); 292 | } 293 | 294 | public function testAcknowledge(): void 295 | { 296 | $this->subscription->expects($this->once()) 297 | ->method('acknowledge'); 298 | 299 | $this->topic->method('subscription') 300 | ->willReturn($this->subscription); 301 | 302 | $this->queue->method('getTopic') 303 | ->willReturn($this->topic); 304 | 305 | $this->queue->acknowledge($this->message); 306 | } 307 | 308 | public function testRepublish(): void 309 | { 310 | $options = ['foo' => 'bar']; 311 | $delay = 60; 312 | $delay_timestamp = Carbon::now()->addSeconds($delay)->getTimestamp(); 313 | 314 | $this->queue->method('getTopic') 315 | ->willReturn($this->topic); 316 | 317 | $this->queue->method('availableAt') 318 | ->willReturn($delay_timestamp); 319 | 320 | $this->topic->expects($this->once()) 321 | ->method('publish') 322 | ->willReturn($this->expectedResult) 323 | ->with( 324 | $this->callback(function ($message) use ($options, $delay_timestamp) { 325 | if (! isset($message['attributes']) || ! is_array($message['attributes'])) { 326 | return false; 327 | } 328 | 329 | foreach ($message['attributes'] as $key => $attribute) { 330 | if (! is_string($attribute) || ! is_string($key)) { 331 | return false; 332 | } 333 | } 334 | 335 | if (! isset($message['attributes']['available_at']) || $message['attributes']['available_at'] !== (string) $delay_timestamp) { 336 | return false; 337 | } 338 | 339 | if (! isset($message['attributes']['foo']) || $message['attributes']['foo'] != $options['foo']) { 340 | return false; 341 | } 342 | 343 | return true; 344 | }), 345 | $this->callback(function ($options) { 346 | if (! is_array($options)) { 347 | return false; 348 | } 349 | 350 | foreach ($options as $key => $option) { 351 | if (! is_string($option) || ! is_string($key)) { 352 | return false; 353 | } 354 | } 355 | 356 | return true; 357 | }) 358 | ); 359 | 360 | $this->queue->republish($this->message, 'test', $options, $delay); 361 | } 362 | 363 | public function testRepublishOptionsOnlyAcceptString(): void 364 | { 365 | $this->expectException(\UnexpectedValueException::class); 366 | 367 | $delay = 60; 368 | $delay_timestamp = Carbon::now()->addSeconds($delay)->getTimestamp(); 369 | 370 | $this->topic->method('subscription') 371 | ->willReturn($this->subscription); 372 | 373 | $this->queue->method('getTopic') 374 | ->willReturn($this->topic); 375 | 376 | $this->queue->method('availableAt') 377 | ->willReturn($delay_timestamp); 378 | 379 | $this->topic->method('publish') 380 | ->willReturn($this->expectedResult); 381 | 382 | $options = [ 383 | 'integer' => 42, 384 | 'array' => [ 385 | 'foo' => 'bar', 386 | ], 387 | 1 => 'wrong key', 388 | 'object' => new \stdClass, 389 | ]; 390 | 391 | $this->queue->republish($this->message, 'test', $options, $delay); 392 | } 393 | 394 | public function testGetTopic(): void 395 | { 396 | $this->topic->method('exists') 397 | ->willReturn(true); 398 | 399 | $this->client->method('topic') 400 | ->willReturn($this->topic); 401 | 402 | /** @var \PHPUnit\Framework\MockObject\MockObject&PubSubQueue $queue */ 403 | $queue = $this->getMockBuilder(PubSubQueue::class) 404 | ->setConstructorArgs([$this->client, 'default']) 405 | ->onlyMethods([]) 406 | ->getMock(); 407 | 408 | $this->assertTrue($queue->getTopic('test') instanceof Topic); 409 | } 410 | 411 | public function testCreateTopicAndReturnIt(): void 412 | { 413 | $this->topic->method('exists') 414 | ->willReturn(false); 415 | 416 | $this->topic->expects($this->once()) 417 | ->method('create') 418 | ->willReturn(true); 419 | 420 | $this->client->method('topic') 421 | ->willReturn($this->topic); 422 | 423 | /** @var \PHPUnit\Framework\MockObject\MockObject&PubSubQueue $queue */ 424 | $queue = $this->getMockBuilder(PubSubQueue::class) 425 | ->setConstructorArgs([$this->client, 'default']) 426 | ->onlyMethods([]) 427 | ->getMock(); 428 | 429 | $this->assertTrue($queue->getTopic('test', true) instanceof Topic); 430 | } 431 | 432 | public function testSubscribtionIsCreated(): void 433 | { 434 | $this->topic->method('subscription') 435 | ->willReturn($this->subscription); 436 | 437 | $this->topic->method('subscribe') 438 | ->willReturn($this->subscription); 439 | 440 | $this->subscription->method('exists') 441 | ->willReturn(false); 442 | 443 | /** @var \PHPUnit\Framework\MockObject\MockObject&PubSubQueue $queue */ 444 | $queue = $this->getMockBuilder(PubSubQueue::class) 445 | ->setConstructorArgs([$this->client, 'default']) 446 | ->onlyMethods([]) 447 | ->getMock(); 448 | 449 | $this->assertTrue($queue->subscribeToTopic($this->topic) instanceof Subscription); 450 | } 451 | 452 | public function testSubscriptionIsRetrieved(): void 453 | { 454 | $this->topic->method('subscription') 455 | ->willReturn($this->subscription); 456 | 457 | $this->subscription->method('exists') 458 | ->willReturn(true); 459 | 460 | /** @var \PHPUnit\Framework\MockObject\MockObject&PubSubQueue $queue */ 461 | $queue = $this->getMockBuilder(PubSubQueue::class) 462 | ->setConstructorArgs([$this->client, 'default']) 463 | ->onlyMethods([]) 464 | ->getMock(); 465 | 466 | $this->assertTrue($queue->subscribeToTopic($this->topic) instanceof Subscription); 467 | } 468 | 469 | public function testGetSubscriberName(): void 470 | { 471 | /** @var \PHPUnit\Framework\MockObject\MockObject&PubSubQueue $queue */ 472 | $queue = $this->getMockBuilder(PubSubQueue::class) 473 | ->setConstructorArgs([$this->client, 'default', 'test-subscriber']) 474 | ->onlyMethods([]) 475 | ->getMock(); 476 | 477 | $this->assertTrue(is_string($queue->getSubscriberName())); 478 | $this->assertEquals($queue->getSubscriberName(), 'test-subscriber'); 479 | } 480 | 481 | public function testGetPubSub(): void 482 | { 483 | $this->assertTrue($this->queue->getPubSub() instanceof PubSubClient); 484 | } 485 | } 486 | --------------------------------------------------------------------------------