diff --git a/.github/workflows/deptrac.yml b/.github/workflows/deptrac.yml index ebaf4df..f481772 100644 --- a/.github/workflows/deptrac.yml +++ b/.github/workflows/deptrac.yml @@ -20,4 +20,4 @@ on: jobs: deptrac: - uses: codeigniter4/.github/.github/workflows/deptrac.yml@main + uses: codeigniter4/.github/.github/workflows/deptrac.yml@CI46 diff --git a/composer.json b/composer.json index 2c843ff..2e00ce2 100644 --- a/composer.json +++ b/composer.json @@ -63,7 +63,7 @@ "cs": "php-cs-fixer fix --ansi --verbose --dry-run --diff", "cs-fix": "php-cs-fixer fix --ansi --verbose --diff", "style": "@cs-fix", - "deduplicate": "phpcpd app/ src/", + "deduplicate": "phpcpd src/ tests/", "inspect": "deptrac analyze --cache-file=build/deptrac.cache", "mutate": "infection --threads=2 --skip-initial-tests --coverage=build/phpunit", "test": "phpunit" diff --git a/docs/basic-usage.md b/docs/basic-usage.md index 5622a82..3204e39 100644 --- a/docs/basic-usage.md +++ b/docs/basic-usage.md @@ -160,6 +160,21 @@ service('queue')->push('emails', 'email', ['message' => 'Email message goes here We will be pushing `email` job to the `emails` queue. +### Sending chained jobs to the queue + +Sending chained jobs is also simple and lets you specify the particular order of the job execution. + +```php +service('queue')->chain(function($chain) { + $chain + ->push('reports', 'generate-report', ['userId' => 123]) + ->push('emails', 'email', ['message' => 'Email message goes here', 'userId' => 123]); +}); +``` + +In the example above, we will send jobs to the `reports` and `emails` queue. First, we will generate a report for given user with the `generate-report` job, after this, we will send an email with `email` job. +The `email` job will be executed only if the `generate-report` job was successful. + ### Consuming the queue Since we sent our sample job to queue `emails`, then we need to run the worker with the appropriate queue: diff --git a/docs/running-queues.md b/docs/running-queues.md index c32184b..5bae1c0 100644 --- a/docs/running-queues.md +++ b/docs/running-queues.md @@ -79,6 +79,40 @@ Note that there is no guarantee that the job will run exactly in 5 minutes. If m We can also combine delayed jobs with priorities. +### Chained jobs + +We can create sequences of jobs that run in a specific order. Each job in the chain will be executed after the previous job has completed successfully. + +```php +service('queue')->chain(function($chain) { + $chain + ->push('reports', 'generate-report', ['userId' => 123]) + ->setPriority('high') // optional + ->push('emails', 'email', ['message' => 'Email message goes here', 'userId' => 123]) + ->setDelay(30); // optional +}); +``` + +As you may notice, we can use the same options as in regular `push()` - we can set priority and delay, which are optional settings. + +#### Important Differences from Regular `push()` + +When using the `chain()` method, there are a few important differences compared to the regular `push()` method: + +1. **Method Order**: Unlike the regular `push()` method where you set the priority and delay before pushing the job, in a chain you must set these properties after calling `push()` for each job: + + ```php + // Regular push() - priority set before pushing + service('queue')->setPriority('high')->push('queue', 'job', []); + + // Chain push() - priority set after pushing + service('queue')->chain(function($chain) { + $chain->push('queue', 'job', [])->setPriority('high'); + }); + ``` + +2. **Configuration Scope**: Each configuration (priority, delay) only applies to the job that was just added to the chain. + ### Running many instances of the same queue As mentioned above, sometimes we may want to have multiple instances of the same command running at the same time. The queue is safe to use in that scenario with all databases as long as you keep the `skipLocked` to `true` in the config file. Only for SQLite3 driver, this setting is not relevant as it provides atomicity without the need for explicit concurrency control. diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 924b3e7..a8bbb27 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -23,6 +23,10 @@ parameters: message: '#Call to an undefined method CodeIgniter\\Queue\\Models\\QueueJobFailedModel::truncate\(\).#' paths: - src/Handlers/BaseHandler.php + - + message: '#If condition is always true.#' + paths: + - src/Commands/QueueWork.php universalObjectCratesClasses: - CodeIgniter\Entity - CodeIgniter\Entity\Entity diff --git a/rector.php b/rector.php index 68f6940..cec36f6 100644 --- a/rector.php +++ b/rector.php @@ -28,6 +28,7 @@ use Rector\EarlyReturn\Rector\Return_\PreparedValueToEarlyReturnRector; use Rector\Php55\Rector\String_\StringClassNameToClassConstantRector; use Rector\Php73\Rector\FuncCall\StringifyStrNeedlesRector; +use Rector\Php81\Rector\ClassMethod\NewInInitializerRector; use Rector\PHPUnit\AnnotationsToAttributes\Rector\Class_\AnnotationWithValueToAttributeRector; use Rector\PHPUnit\AnnotationsToAttributes\Rector\ClassMethod\DataProviderAnnotationToAttributeRector; use Rector\PHPUnit\CodeQuality\Rector\Class_\YieldDataProviderRector; @@ -93,6 +94,10 @@ // Supported from PHPUnit 10 DataProviderAnnotationToAttributeRector::class, + + NewInInitializerRector::class => [ + 'src/Payloads/Payload.php', + ], ]); // auto import fully qualified class names diff --git a/src/Commands/QueueWork.php b/src/Commands/QueueWork.php index 6a4d4fb..bbb81ce 100644 --- a/src/Commands/QueueWork.php +++ b/src/Commands/QueueWork.php @@ -17,6 +17,7 @@ use CodeIgniter\CLI\CLI; use CodeIgniter\Queue\Config\Queue as QueueConfig; use CodeIgniter\Queue\Entities\QueueJob; +use CodeIgniter\Queue\Payloads\PayloadMetadata; use Exception; use Throwable; @@ -247,6 +248,11 @@ private function handleWork(QueueJob $work, QueueConfig $config, ?int $tries, ?i service('queue')->done($work, $config->keepDoneJobs); CLI::write('The processing of this job was successful', 'green'); + + // Check chained jobs + if (isset($payload['metadata']) && $payload['metadata'] !== []) { + $this->processNextJobInChain($payload['metadata']); + } } catch (Throwable $err) { if (isset($job) && ++$work->attempts < ($tries ?? $job->getTries())) { // Schedule for later @@ -262,6 +268,43 @@ private function handleWork(QueueJob $work, QueueConfig $config, ?int $tries, ?i } } + /** + * Process the next job in the chain + */ + private function processNextJobInChain(array $payloadMetadata): void + { + $payloadMetadata = PayloadMetadata::fromArray($payloadMetadata); + + if (! $payloadMetadata->hasChainedJobs()) { + return; + } + + $nextPayload = $payloadMetadata->getChainedJobs()->shift(); + $priority = $nextPayload->getPriority(); + $delay = $nextPayload->getDelay(); + + if ($priority !== null) { + service('queue')->setPriority($priority); + } + + if ($delay !== null) { + service('queue')->setDelay($delay); + } + + if ($payloadMetadata->hasChainedJobs()) { + $nextPayload->setChainedJobs($payloadMetadata->getChainedJobs()); + } + + service('queue')->push( + $nextPayload->getQueue(), + $nextPayload->getJob(), + $nextPayload->getData(), + $nextPayload->getMetadata(), + ); + + CLI::write(sprintf('Chained job: %s has been placed in the queue: %s', $nextPayload->getJob(), $nextPayload->getQueue()), 'green'); + } + private function maxJobsCheck(int $maxJobs, int $countJobs): bool { if ($maxJobs > 0 && $countJobs >= $maxJobs) { diff --git a/src/Handlers/BaseHandler.php b/src/Handlers/BaseHandler.php index 63e0f87..03a6a57 100644 --- a/src/Handlers/BaseHandler.php +++ b/src/Handlers/BaseHandler.php @@ -13,12 +13,16 @@ namespace CodeIgniter\Queue\Handlers; +use Closure; use CodeIgniter\I18n\Time; use CodeIgniter\Queue\Config\Queue as QueueConfig; use CodeIgniter\Queue\Entities\QueueJob; use CodeIgniter\Queue\Entities\QueueJobFailed; use CodeIgniter\Queue\Exceptions\QueueException; use CodeIgniter\Queue\Models\QueueJobFailedModel; +use CodeIgniter\Queue\Payloads\ChainBuilder; +use CodeIgniter\Queue\Payloads\PayloadMetadata; +use CodeIgniter\Queue\Traits\HasQueueValidation; use ReflectionException; use Throwable; @@ -27,13 +31,15 @@ */ abstract class BaseHandler { + use HasQueueValidation; + protected QueueConfig $config; protected ?string $priority = null; protected ?int $delay = null; abstract public function name(): string; - abstract public function push(string $queue, string $job, array $data): bool; + abstract public function push(string $queue, string $job, array $data, ?PayloadMetadata $metadata = null): bool; abstract public function pop(string $queue, array $priorities): ?QueueJob; @@ -45,38 +51,6 @@ abstract public function done(QueueJob $queueJob, bool $keepJob): bool; abstract public function clear(?string $queue = null): bool; - /** - * Set priority for job queue. - */ - public function setPriority(string $priority): static - { - if (! preg_match('/^[a-z_-]+$/', $priority)) { - throw QueueException::forIncorrectPriorityFormat(); - } - - if (strlen($priority) > 64) { - throw QueueException::forTooLongPriorityName(); - } - - $this->priority = $priority; - - return $this; - } - - /** - * Set delay for job queue (in seconds). - */ - public function setDelay(int $delay): static - { - if ($delay < 0) { - throw QueueException::forIncorrectDelayValue(); - } - - $this->delay = $delay; - - return $this; - } - /** * Retry failed job. * @@ -104,7 +78,7 @@ public function retry(?int $id, ?string $queue): int } /** - * Delete failed job by ID. + * Delete a failed job by ID. */ public function forget(int $id): bool { @@ -150,6 +124,43 @@ public function listFailed(?string $queue): array ->findAll(); } + /** + * Set delay for job queue (in seconds). + */ + public function setDelay(int $delay): static + { + $this->validateDelay($delay); + + $this->delay = $delay; + + return $this; + } + + /** + * Set priority for job queue. + */ + public function setPriority(string $priority): static + { + $this->validatePriority($priority); + + $this->priority = $priority; + + return $this; + } + + /** + * Create a job chain on the specified queue + * + * @param Closure $callback Chain definition callback + */ + public function chain(Closure $callback): bool + { + $chainBuilder = new ChainBuilder($this); + $callback($chainBuilder); + + return $chainBuilder->dispatch(); + } + /** * Log failed job. * diff --git a/src/Handlers/DatabaseHandler.php b/src/Handlers/DatabaseHandler.php index 71bd497..88869b6 100644 --- a/src/Handlers/DatabaseHandler.php +++ b/src/Handlers/DatabaseHandler.php @@ -19,7 +19,8 @@ use CodeIgniter\Queue\Enums\Status; use CodeIgniter\Queue\Interfaces\QueueInterface; use CodeIgniter\Queue\Models\QueueJobModel; -use CodeIgniter\Queue\Payload; +use CodeIgniter\Queue\Payloads\Payload; +use CodeIgniter\Queue\Payloads\PayloadMetadata; use ReflectionException; use Throwable; @@ -46,13 +47,13 @@ public function name(): string * * @throws ReflectionException */ - public function push(string $queue, string $job, array $data): bool + public function push(string $queue, string $job, array $data, ?PayloadMetadata $metadata = null): bool { $this->validateJobAndPriority($queue, $job); $queueJob = new QueueJob([ 'queue' => $queue, - 'payload' => new Payload($job, $data), + 'payload' => new Payload($job, $data, $metadata), 'priority' => $this->priority, 'status' => Status::PENDING->value, 'attempts' => 0, diff --git a/src/Handlers/PredisHandler.php b/src/Handlers/PredisHandler.php index e781955..4b64990 100644 --- a/src/Handlers/PredisHandler.php +++ b/src/Handlers/PredisHandler.php @@ -20,7 +20,8 @@ use CodeIgniter\Queue\Entities\QueueJob; use CodeIgniter\Queue\Enums\Status; use CodeIgniter\Queue\Interfaces\QueueInterface; -use CodeIgniter\Queue\Payload; +use CodeIgniter\Queue\Payloads\Payload; +use CodeIgniter\Queue\Payloads\PayloadMetadata; use Exception; use Predis\Client; use Throwable; @@ -58,7 +59,7 @@ public function name(): string /** * Add job to the queue. */ - public function push(string $queue, string $job, array $data): bool + public function push(string $queue, string $job, array $data, ?PayloadMetadata $metadata = null): bool { $this->validateJobAndPriority($queue, $job); @@ -69,7 +70,7 @@ public function push(string $queue, string $job, array $data): bool $queueJob = new QueueJob([ 'id' => random_string('numeric', 16), 'queue' => $queue, - 'payload' => new Payload($job, $data), + 'payload' => new Payload($job, $data, $metadata), 'priority' => $this->priority, 'status' => Status::PENDING->value, 'attempts' => 0, diff --git a/src/Handlers/RedisHandler.php b/src/Handlers/RedisHandler.php index 9fc49f2..dc86d5b 100644 --- a/src/Handlers/RedisHandler.php +++ b/src/Handlers/RedisHandler.php @@ -20,7 +20,8 @@ use CodeIgniter\Queue\Entities\QueueJob; use CodeIgniter\Queue\Enums\Status; use CodeIgniter\Queue\Interfaces\QueueInterface; -use CodeIgniter\Queue\Payload; +use CodeIgniter\Queue\Payloads\Payload; +use CodeIgniter\Queue\Payloads\PayloadMetadata; use Redis; use RedisException; use Throwable; @@ -75,7 +76,7 @@ public function name(): string * * @throws RedisException */ - public function push(string $queue, string $job, array $data): bool + public function push(string $queue, string $job, array $data, ?PayloadMetadata $metadata = null): bool { $this->validateJobAndPriority($queue, $job); @@ -86,7 +87,7 @@ public function push(string $queue, string $job, array $data): bool $queueJob = new QueueJob([ 'id' => random_string('numeric', 16), 'queue' => $queue, - 'payload' => new Payload($job, $data), + 'payload' => new Payload($job, $data, $metadata), 'priority' => $this->priority, 'status' => Status::PENDING->value, 'attempts' => 0, diff --git a/src/Payload.php b/src/Payload.php deleted file mode 100644 index 9937fa0..0000000 --- a/src/Payload.php +++ /dev/null @@ -1,31 +0,0 @@ - - * - * For the full copyright and license information, please view - * the LICENSE file that was distributed with this source code. - */ - -namespace CodeIgniter\Queue; - -use JsonSerializable; - -class Payload implements JsonSerializable -{ - public function __construct(protected string $job, protected array $data) - { - } - - public function jsonSerialize(): array - { - return [ - 'job' => $this->job, - 'data' => $this->data, - ]; - } -} diff --git a/src/Payloads/ChainBuilder.php b/src/Payloads/ChainBuilder.php new file mode 100644 index 0000000..73479d2 --- /dev/null +++ b/src/Payloads/ChainBuilder.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Queue\Payloads; + +use CodeIgniter\Queue\Handlers\BaseHandler; + +class ChainBuilder +{ + /** + * Collection of jobs in the chain + */ + protected PayloadCollection $payloads; + + public function __construct(protected BaseHandler $handler) + { + $this->payloads = new PayloadCollection(); + } + + /** + * Add a job to the chain + */ + public function push(string $queue, string $jobName, array $data = []): ChainElement + { + $payload = new Payload($jobName, $data); + + $payload->setQueue($queue); + + $this->payloads->add($payload); + + return new ChainElement($payload, $this); + } + + /** + * Dispatch the chain of jobs + */ + public function dispatch(): bool + { + if ($this->payloads->count() === 0) { + return true; + } + + $current = $this->payloads->shift(); + $priority = $current->getPriority(); + $delay = $current->getDelay(); + + if ($priority !== null) { + $this->handler->setPriority($priority); + } + + if ($delay !== null) { + $this->handler->setDelay($delay); + } + + // Set chained jobs for the next job + if ($this->payloads->count() > 0) { + $current->setChainedJobs($this->payloads); + } + + // Push to the queue with the specified queue name + return $this->handler->push( + $current->getQueue(), + $current->getJob(), + $current->getData(), + $current->getMetadata(), + ); + } +} diff --git a/src/Payloads/ChainElement.php b/src/Payloads/ChainElement.php new file mode 100644 index 0000000..d8d7bc3 --- /dev/null +++ b/src/Payloads/ChainElement.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Queue\Payloads; + +class ChainElement +{ + public function __construct(protected Payload $payload, protected ChainBuilder $chainBuilder) + { + } + + /** + * Set priority for this specific job + */ + public function setPriority(string $priority): self + { + $this->payload->setPriority($priority); + + return $this; + } + + /** + * Set delay for this specific job + */ + public function setDelay(int $delay): self + { + $this->payload->setDelay($delay); + + return $this; + } + + /** + * Push the next job in the chain (method chaining) + */ + public function push(string $queue, string $jobName, array $data = []): ChainElement + { + return $this->chainBuilder->push($queue, $jobName, $data); + } +} diff --git a/src/Payloads/Payload.php b/src/Payloads/Payload.php new file mode 100644 index 0000000..8539a87 --- /dev/null +++ b/src/Payloads/Payload.php @@ -0,0 +1,150 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Queue\Payloads; + +use CodeIgniter\Queue\Exceptions\QueueException; +use CodeIgniter\Queue\Traits\HasQueueValidation; +use JsonSerializable; + +class Payload implements JsonSerializable +{ + use HasQueueValidation; + + /** + * Job metadata + */ + protected PayloadMetadata $metadata; + + public function __construct(protected string $job, protected array $data, ?PayloadMetadata $metadata = null) + { + $this->metadata = $metadata ?? new PayloadMetadata(); + } + + public function getJob(): string + { + return $this->job; + } + + public function getData(): array + { + return $this->data; + } + + public function getMetadata(): PayloadMetadata + { + return $this->metadata; + } + + public function setMetadata(PayloadMetadata $metadata): self + { + $this->metadata = $metadata; + + return $this; + } + + /** + * Set the queue name + * + * @throws QueueException + */ + public function setQueue(string $queue): self + { + $this->validateQueue($queue); + + $this->metadata->set('queue', $queue); + + return $this; + } + + public function getQueue(): ?string + { + return $this->metadata->get('queue'); + } + + /** + * Set the priority + * + * @throws QueueException + */ + public function setPriority(string $priority): self + { + $this->validatePriority($priority); + + $this->metadata->set('priority', $priority); + + return $this; + } + + public function getPriority(): ?string + { + return $this->metadata->get('priority'); + } + + /** + * Set the delay + * + * @throws QueueException + */ + public function setDelay(int $delay): self + { + $this->validateDelay($delay); + + $this->metadata->set('delay', $delay); + + return $this; + } + + public function getDelay(): ?int + { + return $this->metadata->get('delay'); + } + + public function setChainedJobs(PayloadCollection $payloads): self + { + $this->metadata->setChainedJobs($payloads); + + return $this; + } + + public function getChainedJobs(): ?PayloadCollection + { + return $this->metadata->getChainedJobs(); + } + + public function hasChainedJobs(): bool + { + return $this->metadata->hasChainedJobs(); + } + + public function jsonSerialize(): array + { + return [ + 'job' => $this->job, + 'data' => $this->data, + 'metadata' => $this->metadata, + ]; + } + + /** + * Create a Payload from an array + */ + public static function fromArray(array $data): self + { + $job = $data['job'] ?? ''; + $jobData = $data['data'] ?? []; + $metadata = isset($data['metadata']) ? PayloadMetadata::fromArray($data['metadata']) : null; + + return new self($job, $jobData, $metadata); + } +} diff --git a/src/Payloads/PayloadCollection.php b/src/Payloads/PayloadCollection.php new file mode 100644 index 0000000..871c96a --- /dev/null +++ b/src/Payloads/PayloadCollection.php @@ -0,0 +1,106 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Queue\Payloads; + +use ArrayIterator; +use Countable; +use IteratorAggregate; +use JsonSerializable; + +/** + * @template T + * + * @implements IteratorAggregate + */ +class PayloadCollection implements IteratorAggregate, Countable, JsonSerializable +{ + /** + * Create a new payload collection + * + * @param list $items + */ + public function __construct(protected array $items = []) + { + } + + /** + * Add a payload to the collection + */ + public function add(Payload $payload): self + { + $this->items[] = $payload; + + return $this; + } + + /** + * Get the first payload and remove it. + */ + public function shift(): ?Payload + { + if ($this->count() === 0) { + return null; + } + + return array_shift($this->items); + } + + /** + * Convert the collection to an array + */ + public function toArray(): array + { + $result = []; + + foreach ($this->items as $payload) { + $result[] = $payload->jsonSerialize(); + } + + return $result; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } + + public function count(): int + { + return count($this->items); + } + + /** + * @return ArrayIterator + */ + public function getIterator(): ArrayIterator + { + return new ArrayIterator($this->items); + } + + /** + * Create a new PayloadCollection from an array + */ + public static function fromArray(array $payloads): self + { + $collection = new self(); + + foreach ($payloads as $payload) { + if (isset($payload['job'], $payload['data'])) { + $collection->add(Payload::fromArray($payload)); + } + } + + return $collection; + } +} diff --git a/src/Payloads/PayloadMetadata.php b/src/Payloads/PayloadMetadata.php new file mode 100644 index 0000000..17c8833 --- /dev/null +++ b/src/Payloads/PayloadMetadata.php @@ -0,0 +1,133 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Queue\Payloads; + +use JsonSerializable; + +class PayloadMetadata implements JsonSerializable +{ + public function __construct(protected array $data = []) + { + } + + /** + * Set chained jobs + */ + public function setChainedJobs(?PayloadCollection $payloads): self + { + if ($payloads !== null) { + $this->data['chainedJobs'] = $payloads; + } else { + unset($this->data['chainedJobs']); + } + + return $this; + } + + /** + * Get chained jobs + */ + public function getChainedJobs(): ?PayloadCollection + { + return $this->data['chainedJobs'] ?? null; + } + + /** + * Check if has chained jobs + */ + public function hasChainedJobs(): bool + { + return isset($this->data['chainedJobs']) && $this->data['chainedJobs']->count() > 0; + } + + /** + * Set a generic metadata value + */ + public function set(string $key, mixed $value): self + { + $this->data[$key] = $value; + + return $this; + } + + /** + * Get a generic metadata value + * + * @param mixed|null $default + */ + public function get(string $key, $default = null) + { + return $this->data[$key] ?? $default; + } + + /** + * Check if a metadata key exists + */ + public function has(string $key): bool + { + return isset($this->data[$key]); + } + + /** + * Remove a metadata key + */ + public function remove(string $key): self + { + unset($this->data[$key]); + + return $this; + } + + /** + * Get all metadata as an array + */ + public function toArray(): array + { + return $this->data; + } + + /** + * JSON serialize implementation + */ + public function jsonSerialize(): array + { + return $this->data; + } + + public static function fromArray(array $data): PayloadMetadata + { + $metadata = new self(); + + foreach ($data as $key => $value) { + // Handle chainedJobs specially + if ($key === 'chainedJobs' && is_array($value)) { + $payloadCollection = new PayloadCollection(); + + foreach ($value as $jobData) { + if (isset($jobData['job'], $jobData['data'])) { + $payload = Payload::fromArray($jobData); + $payloadCollection->add($payload); + } + } + + $metadata->setChainedJobs($payloadCollection); + } else { + // Regular metadata + $metadata->set($key, $value); + } + } + + return $metadata; + } +} diff --git a/src/Traits/HasQueueValidation.php b/src/Traits/HasQueueValidation.php new file mode 100644 index 0000000..aa66593 --- /dev/null +++ b/src/Traits/HasQueueValidation.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Queue\Traits; + +use CodeIgniter\Queue\Exceptions\QueueException; + +trait HasQueueValidation +{ + /** + * Validate priority value. + * + * @throws QueueException + */ + protected function validatePriority(string $priority): void + { + if (! preg_match('/^[a-z_-]+$/', $priority)) { + throw QueueException::forIncorrectPriorityFormat(); + } + + if (strlen($priority) > 64) { + throw QueueException::forTooLongPriorityName(); + } + } + + /** + * Validate delay value. + * + * @throws QueueException + */ + protected function validateDelay(int $delay): void + { + if ($delay < 0) { + throw QueueException::forIncorrectDelayValue(); + } + } + + /** + * Validate queue name. + * + * @throws QueueException + */ + protected function validateQueue(string $queue): void + { + if (! preg_match('/^[a-z0-9_-]+$/', $queue)) { + throw QueueException::forIncorrectQueueFormat(); + } + + if (strlen($queue) > 64) { + throw QueueException::forTooLongQueueName(); + } + } +} diff --git a/tests/Commands/QueueWorkTest.php b/tests/Commands/QueueWorkTest.php index 8bbb294..dd5a13d 100644 --- a/tests/Commands/QueueWorkTest.php +++ b/tests/Commands/QueueWorkTest.php @@ -111,4 +111,48 @@ public function testRunWithQueueSucceed(): void $this->assertSame('The processing of this job was successful', $this->getLine(4)); $this->assertSame('No job available. Stopping.', $this->getLine(7)); } + + public function testRunWithChainedQueueSucceed(): void + { + Time::setTestNow('2023-12-19 14:15:16'); + + fake(QueueJobModel::class, [ + 'connection' => 'database', + 'queue' => 'test', + 'payload' => [ + 'job' => 'success', + 'data' => ['key' => 'value'], + 'metadata' => [ + 'queue' => 'queue', + 'chainedJobs' => [ + [ + 'job' => 'success', 'data' => ['key2' => 'value2'], 'metadata' => [ + 'queue' => 'queue', + 'priority' => 'high', + 'delay' => 10, + ], + ], + ], + ], + ], + 'priority' => 'default', + 'status' => 0, + 'attempts' => 0, + 'available_at' => 1_702_977_074, + ]); + + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + + $this->assertNotFalse(command('queue:work test sleep 1 --stop-when-empty')); + $this->parseOutput(CITestStreamFilter::$buffer); + + CITestStreamFilter::removeOutputFilter(); + + $this->assertSame('Listening for the jobs with the queue: test', $this->getLine(0)); + $this->assertSame('Starting a new job: success, with ID: 1', $this->getLine(3)); + $this->assertSame('The processing of this job was successful', $this->getLine(4)); + $this->assertSame('Chained job: success has been placed in the queue: queue', $this->getLine(5)); + $this->assertSame('No job available. Stopping.', $this->getLine(8)); + } } diff --git a/tests/DatabaseHandlerTest.php b/tests/DatabaseHandlerTest.php index 4c5b159..507f917 100644 --- a/tests/DatabaseHandlerTest.php +++ b/tests/DatabaseHandlerTest.php @@ -88,7 +88,7 @@ public function testPush(): void $this->assertTrue($result); $this->seeInDatabase('queue_jobs', [ 'queue' => 'queue', - 'payload' => json_encode(['job' => 'success', 'data' => ['key' => 'value']]), + 'payload' => json_encode(['job' => 'success', 'data' => ['key' => 'value'], 'metadata' => []]), 'available_at' => 1703859316, ]); } @@ -106,7 +106,7 @@ public function testPushWithPriority(): void $this->assertTrue($result); $this->seeInDatabase('queue_jobs', [ 'queue' => 'queue', - 'payload' => json_encode(['job' => 'success', 'data' => ['key' => 'value']]), + 'payload' => json_encode(['job' => 'success', 'data' => ['key' => 'value'], 'metadata' => []]), 'priority' => 'high', 'available_at' => 1703859316, ]); @@ -125,7 +125,7 @@ public function testPushAndPopWithPriority(): void $this->assertTrue($result); $this->seeInDatabase('queue_jobs', [ 'queue' => 'queue', - 'payload' => json_encode(['job' => 'success', 'data' => ['key1' => 'value1']]), + 'payload' => json_encode(['job' => 'success', 'data' => ['key1' => 'value1'], 'metadata' => []]), 'priority' => 'low', 'available_at' => 1703859316, ]); @@ -135,19 +135,19 @@ public function testPushAndPopWithPriority(): void $this->assertTrue($result); $this->seeInDatabase('queue_jobs', [ 'queue' => 'queue', - 'payload' => json_encode(['job' => 'success', 'data' => ['key2' => 'value2']]), + 'payload' => json_encode(['job' => 'success', 'data' => ['key2' => 'value2'], 'metadata' => []]), 'priority' => 'high', 'available_at' => 1703859316, ]); $result = $handler->pop('queue', ['high', 'low']); $this->assertInstanceOf(QueueJob::class, $result); - $payload = ['job' => 'success', 'data' => ['key2' => 'value2']]; + $payload = ['job' => 'success', 'data' => ['key2' => 'value2'], 'metadata' => []]; $this->assertSame($payload, $result->payload); $result = $handler->pop('queue', ['high', 'low']); $this->assertInstanceOf(QueueJob::class, $result); - $payload = ['job' => 'success', 'data' => ['key1' => 'value1']]; + $payload = ['job' => 'success', 'data' => ['key1' => 'value1'], 'metadata' => []]; $this->assertSame($payload, $result->payload); } @@ -167,13 +167,87 @@ public function testPushWithDelay(): void $this->seeInDatabase('queue_jobs', [ 'queue' => 'queue-delay', - 'payload' => json_encode(['job' => 'success', 'data' => ['key' => 'value']]), + 'payload' => json_encode(['job' => 'success', 'data' => ['key' => 'value'], 'metadata' => []]), 'available_at' => $availableAt, ]); $this->assertEqualsWithDelta(MINUTE, $availableAt - Time::now()->getTimestamp(), 1); } + /** + * @throws Exception + */ + public function testChain(): void + { + Time::setTestNow('2023-12-29 14:15:16'); + + $handler = new DatabaseHandler($this->config); + $result = $handler->chain(static function ($chain): void { + $chain + ->push('queue', 'success', ['key1' => 'value1']) + ->push('queue', 'success', ['key2' => 'value2']); + }); + + $this->assertTrue($result); + $this->seeInDatabase('queue_jobs', [ + 'queue' => 'queue', + 'payload' => json_encode([ + 'job' => 'success', + 'data' => ['key1' => 'value1'], + 'metadata' => [ + 'queue' => 'queue', + 'chainedJobs' => [ + [ + 'job' => 'success', 'data' => ['key2' => 'value2'], 'metadata' => ['queue' => 'queue']], + ], + ], + ]), + 'available_at' => 1703859316, + ]); + } + + public function testChainWithPriorityAndDelay(): void + { + Time::setTestNow('2023-12-29 14:15:16'); + + $handler = new DatabaseHandler($this->config); + $result = $handler->chain(static function ($chain): void { + $chain + ->push('queue', 'success', ['key1' => 'value1']) + ->setPriority('high') + ->setDelay(60) + ->push('queue', 'success', ['key2' => 'value2']) + ->setPriority('low') + ->setDelay(120); + }); + + $this->assertTrue($result); + $this->seeInDatabase('queue_jobs', [ + 'queue' => 'queue', + 'payload' => json_encode([ + 'job' => 'success', + 'data' => ['key1' => 'value1'], + 'metadata' => [ + 'queue' => 'queue', + 'priority' => 'high', + 'delay' => 60, + 'chainedJobs' => [ + [ + 'job' => 'success', + 'data' => ['key2' => 'value2'], + 'metadata' => [ + 'queue' => 'queue', + 'priority' => 'low', + 'delay' => 120, + ], + ], + ], + ], + ]), + 'available_at' => 1703859316 + 60, // Adding delay to available_at + ]); + } + public function testPushWithDelayException(): void { $this->expectException(QueueException::class); @@ -384,7 +458,7 @@ public function testRetry(): void $this->seeInDatabase('queue_jobs', [ 'id' => 3, 'queue' => 'queue1', - 'payload' => json_encode(['job' => 'failure', 'data' => []]), + 'payload' => json_encode(['job' => 'failure', 'data' => [], 'metadata' => []]), ]); $this->dontSeeInDatabase('queue_jobs_failed', [ 'id' => 1, diff --git a/tests/Payloads/ChainBuilderTest.php b/tests/Payloads/ChainBuilderTest.php new file mode 100644 index 0000000..304f5ae --- /dev/null +++ b/tests/Payloads/ChainBuilderTest.php @@ -0,0 +1,160 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Payloads; + +use CodeIgniter\I18n\Time; +use CodeIgniter\Queue\Handlers\DatabaseHandler; +use CodeIgniter\Queue\Payloads\ChainBuilder; +use CodeIgniter\Queue\Payloads\ChainElement; +use Tests\Support\Config\Queue as QueueConfig; +use Tests\Support\Database\Seeds\TestDatabaseQueueSeeder; +use Tests\Support\TestCase; + +/** + * @internal + */ +final class ChainBuilderTest extends TestCase +{ + protected $seed = TestDatabaseQueueSeeder::class; + private QueueConfig $config; + + protected function setUp(): void + { + parent::setUp(); + + $this->config = config(QueueConfig::class); + } + + public function testChainBuilder(): void + { + $handler = new DatabaseHandler($this->config); + $chainBuilder = new ChainBuilder($handler); + + $this->assertInstanceOf(ChainBuilder::class, $chainBuilder); + } + + public function testPush(): void + { + $handler = new DatabaseHandler($this->config); + $chainBuilder = new ChainBuilder($handler); + + $chainElement = $chainBuilder->push('queue', 'job', ['data' => 'value']); + + $this->assertInstanceOf(ChainElement::class, $chainElement); + } + + public function testChainWithSingleJob(): void + { + Time::setTestNow('2023-12-29 14:15:16'); + + $handler = new DatabaseHandler($this->config); + $result = $handler->chain(static function ($chain): void { + $chain->push('queue', 'success', ['key' => 'value']); + }); + + $this->assertTrue($result); + $this->seeInDatabase('queue_jobs', [ + 'queue' => 'queue', + 'payload' => json_encode([ + 'job' => 'success', + 'data' => ['key' => 'value'], + 'metadata' => [ + 'queue' => 'queue', + ], + ]), + 'available_at' => 1703859316, + ]); + } + + public function testEmptyChain(): void + { + $handler = new DatabaseHandler($this->config); + $result = $handler->chain(static function ($chain): void { + // No jobs added + }); + + $this->assertTrue($result); + $this->seeInDatabase('queue_jobs', []); + } + + public function testMultipleDifferentQueues(): void + { + Time::setTestNow('2023-12-29 14:15:16'); + + $handler = new DatabaseHandler($this->config); + $result = $handler->chain(static function ($chain): void { + $chain + ->push('queue1', 'success', ['key1' => 'value1']) + ->push('queue2', 'success', ['key2' => 'value2']); + }); + + $this->assertTrue($result); + $this->seeInDatabase('queue_jobs', [ + 'queue' => 'queue1', + 'payload' => json_encode([ + 'job' => 'success', + 'data' => ['key1' => 'value1'], + 'metadata' => [ + 'queue' => 'queue1', + 'chainedJobs' => [ + [ + 'job' => 'success', + 'data' => ['key2' => 'value2'], + 'metadata' => ['queue' => 'queue2'], + ], + ], + ], + ]), + 'available_at' => 1703859316, + ]); + } + + public function testChainWithManyJobs(): void + { + Time::setTestNow('2023-12-29 14:15:16'); + + $handler = new DatabaseHandler($this->config); + $result = $handler->chain(static function ($chain): void { + $chain + ->push('queue', 'success', ['key1' => 'value1']) + ->push('queue', 'success', ['key2' => 'value2']) + ->push('queue', 'success', ['key3' => 'value3']); + }); + + $this->assertTrue($result); + $this->seeInDatabase('queue_jobs', [ + 'queue' => 'queue', + 'payload' => json_encode([ + 'job' => 'success', + 'data' => ['key1' => 'value1'], + 'metadata' => [ + 'queue' => 'queue', + 'chainedJobs' => [ + [ + 'job' => 'success', + 'data' => ['key2' => 'value2'], + 'metadata' => ['queue' => 'queue'], + ], + [ + 'job' => 'success', + 'data' => ['key3' => 'value3'], + 'metadata' => ['queue' => 'queue'], + ], + ], + ], + ]), + 'available_at' => 1703859316, + ]); + } +} diff --git a/tests/Payloads/ChainElementTest.php b/tests/Payloads/ChainElementTest.php new file mode 100644 index 0000000..2bb232a --- /dev/null +++ b/tests/Payloads/ChainElementTest.php @@ -0,0 +1,109 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace App\ThirdParty\queue\tests\Payloads; + +use CodeIgniter\Queue\Payloads\ChainBuilder; +use CodeIgniter\Queue\Payloads\ChainElement; +use CodeIgniter\Queue\Payloads\Payload; +use CodeIgniter\Queue\Payloads\PayloadMetadata; +use Tests\Support\TestCase; + +/** + * @internal + */ +final class ChainElementTest extends TestCase +{ + private Payload $payload; + private ChainBuilder $chainBuilder; + private ChainElement $chainElement; + + protected function setUp(): void + { + parent::setUp(); + + // Create a payload object + $this->payload = new Payload('job', ['key' => 'value']); + $this->payload->setQueue('queue'); + + // Create a mock ChainBuilder + $this->chainBuilder = $this->createMock(ChainBuilder::class); + + // Create the ChainElement to test + $this->chainElement = new ChainElement($this->payload, $this->chainBuilder); + } + + public function testSetPriority(): void + { + $result = $this->chainElement->setPriority('high'); + + $this->assertInstanceOf(ChainElement::class, $result); + $this->assertSame('high', $this->payload->getPriority()); + } + + public function testSetDelay(): void + { + $result = $this->chainElement->setDelay(60); + + $this->assertInstanceOf(ChainElement::class, $result); + $this->assertSame(60, $this->payload->getDelay()); + } + + public function testPush(): void + { + $nextPayload = new Payload('nextJob', ['nextKey' => 'nextValue']); + $nextElement = new ChainElement($nextPayload, $this->chainBuilder); + + /** @phpstan-ignore-next-line */ + $this->chainBuilder->expects($this->once()) + ->method('push') + ->with('queue2', 'job2', ['data' => 'value2']) + ->willReturn($nextElement); + + $result = $this->chainElement->push('queue2', 'job2', ['data' => 'value2']); + + $this->assertInstanceOf(ChainElement::class, $result); + $this->assertSame($nextElement, $result); + } + + public function testMultipleMethodChaining(): void + { + $chainBuilder = $this->createMock(ChainBuilder::class); + $chainBuilder->method('push')->willReturnSelf(); + + $payload = new Payload('job', ['key' => 'value']); + + $chainElement = new ChainElement($payload, $chainBuilder); + + $chainElement + ->setPriority('medium') + ->setDelay(30); + + $this->assertSame('medium', $payload->getPriority()); + $this->assertSame(30, $payload->getDelay()); + } + + public function testCorrectMetadataModification(): void + { + $metadata = new PayloadMetadata(); + $payload = new Payload('job', ['key' => 'value'], $metadata); + + $chainElement = new ChainElement($payload, $this->chainBuilder); + + $chainElement->setPriority('low'); + $chainElement->setDelay(120); + + $this->assertSame('low', $payload->getMetadata()->get('priority')); + $this->assertSame(120, $payload->getMetadata()->get('delay')); + } +} diff --git a/tests/Payloads/PayloadCollectionTest.php b/tests/Payloads/PayloadCollectionTest.php new file mode 100644 index 0000000..13ef3d6 --- /dev/null +++ b/tests/Payloads/PayloadCollectionTest.php @@ -0,0 +1,185 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace App\ThirdParty\queue\tests\Payloads; + +use ArrayIterator; +use CodeIgniter\Queue\Payloads\Payload; +use CodeIgniter\Queue\Payloads\PayloadCollection; +use Tests\Support\TestCase; + +/** + * @internal + */ +final class PayloadCollectionTest extends TestCase +{ + private PayloadCollection $collection; + private Payload $payload1; + private Payload $payload2; + + protected function setUp(): void + { + parent::setUp(); + + // Create sample payloads + $this->payload1 = new Payload('job1', ['key1' => 'value1']); + $this->payload1->setQueue('queue1'); + + $this->payload2 = new Payload('job2', ['key2' => 'value2']); + $this->payload2->setQueue('queue2'); + + // Create an empty collection + $this->collection = new PayloadCollection(); + } + + public function testEmptyCollectionCount(): void + { + $this->assertCount(0, $this->collection); + } + + public function testAddPayload(): void + { + $result = $this->collection->add($this->payload1); + + $this->assertInstanceOf(PayloadCollection::class, $result); + $this->assertCount(1, $this->collection); + } + + public function testAddMultiplePayloads(): void + { + $this->collection->add($this->payload1); + $this->collection->add($this->payload2); + + $this->assertCount(2, $this->collection); + } + + public function testShiftPayload(): void + { + $this->collection->add($this->payload1); + $this->collection->add($this->payload2); + + $first = $this->collection->shift(); + + $this->assertSame($this->payload1, $first); + $this->assertCount(1, $this->collection); + } + + public function testShiftFromEmptyCollection(): void + { + $result = $this->collection->shift(); + + $this->assertNull($result); + } + + public function testGetIterator(): void + { + $this->collection->add($this->payload1); + $this->collection->add($this->payload2); + + $iterator = $this->collection->getIterator(); + + $this->assertInstanceOf(ArrayIterator::class, $iterator); + $this->assertCount(2, $iterator); + } + + public function testToArray(): void + { + $this->collection->add($this->payload1); + $this->collection->add($this->payload2); + + $array = $this->collection->toArray(); + + $this->assertCount(2, $array); + + // Check array structure + $this->assertArrayHasKey('job', $array[0]); + $this->assertArrayHasKey('data', $array[0]); + $this->assertArrayHasKey('metadata', $array[0]); + + $this->assertSame('job1', $array[0]['job']); + $this->assertSame(['key1' => 'value1'], $array[0]['data']); + } + + public function testJsonSerialize(): void + { + $this->collection->add($this->payload1); + $this->collection->add($this->payload2); + + $json = json_encode($this->collection); + $decoded = json_decode($json, true); + + $this->assertIsArray($decoded); + $this->assertCount(2, $decoded); + $this->assertSame('job1', $decoded[0]['job']); + $this->assertSame('job2', $decoded[1]['job']); + } + + public function testFromArray(): void + { + $arrayData = [ + [ + 'job' => 'job1', + 'data' => ['key1' => 'value1'], + 'metadata' => ['queue' => 'queue1'], + ], + [ + 'job' => 'job2', + 'data' => ['key2' => 'value2'], + 'metadata' => ['queue' => 'queue2'], + ], + ]; + + $collection = PayloadCollection::fromArray($arrayData); + + $this->assertInstanceOf(PayloadCollection::class, $collection); + $this->assertCount(2, $collection); + + $first = $collection->shift(); + $this->assertInstanceOf(Payload::class, $first); + $this->assertSame('job1', $first->getJob()); + $this->assertSame(['key1' => 'value1'], $first->getData()); + } + + public function testInvalidDataInFromArray(): void + { + $arrayData = [ + ['invalid' => 'data'], // Missing job and data + [ + 'job' => 'job2', + 'data' => ['key2' => 'value2'], + ], + ]; + + $collection = PayloadCollection::fromArray($arrayData); + + // Should only have created one valid payload + $this->assertCount(1, $collection); + } + + public function testIteration(): void + { + $this->collection->add($this->payload1); + $this->collection->add($this->payload2); + + $count = 0; + $jobs = []; + + foreach ($this->collection as $payload) { + $count++; + $jobs[] = $payload->getJob(); + } + + $this->assertSame(2, $count); + $this->assertSame(['job1', 'job2'], $jobs); + } +} diff --git a/tests/Payloads/PayloadMetadataTest.php b/tests/Payloads/PayloadMetadataTest.php new file mode 100644 index 0000000..e0af535 --- /dev/null +++ b/tests/Payloads/PayloadMetadataTest.php @@ -0,0 +1,233 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace App\ThirdParty\queue\tests\Payloads; + +use CodeIgniter\Queue\Payloads\Payload; +use CodeIgniter\Queue\Payloads\PayloadCollection; +use CodeIgniter\Queue\Payloads\PayloadMetadata; +use Tests\Support\TestCase; + +/** + * @internal + */ +final class PayloadMetadataTest extends TestCase +{ + private PayloadMetadata $metadata; + + protected function setUp(): void + { + parent::setUp(); + $this->metadata = new PayloadMetadata(); + } + + public function testEmptyMetadata(): void + { + $this->assertSame([], $this->metadata->toArray()); + } + + public function testSetAndGetGenericValue(): void + { + $this->metadata->set('key', 'value'); + + $this->assertSame('value', $this->metadata->get('key')); + } + + public function testGetWithDefault(): void + { + $this->assertSame('default', $this->metadata->get('nonexistent', 'default')); + } + + public function testHasKey(): void + { + $this->metadata->set('key', 'value'); + + $this->assertTrue($this->metadata->has('key')); + $this->assertFalse($this->metadata->has('nonexistent')); + } + + public function testRemoveKey(): void + { + $this->metadata->set('key', 'value'); + $this->metadata->remove('key'); + + $this->assertFalse($this->metadata->has('key')); + } + + public function testSetAndGetChainedJobs(): void + { + $payload1 = new Payload('job1', ['key1' => 'value1']); + $payload2 = new Payload('job2', ['key2' => 'value2']); + + $payloads = new PayloadCollection(); + $payloads->add($payload1); + $payloads->add($payload2); + + $this->metadata->setChainedJobs($payloads); + + $result = $this->metadata->getChainedJobs(); + + $this->assertInstanceOf(PayloadCollection::class, $result); + $this->assertCount(2, $result); + } + + public function testSetChainedJobsToNull(): void + { + $payload = new Payload('job', ['key' => 'value']); + $payloads = new PayloadCollection(); + $payloads->add($payload); + + $this->metadata->setChainedJobs($payloads); + + // Then set to null + $this->metadata->setChainedJobs(null); + + $this->assertNull($this->metadata->getChainedJobs()); + $this->assertFalse($this->metadata->hasChainedJobs()); + } + + public function testHasChainedJobs(): void + { + $this->assertFalse($this->metadata->hasChainedJobs()); + + $payload = new Payload('job', ['key' => 'value']); + $payloads = new PayloadCollection(); + $payloads->add($payload); + + $this->metadata->setChainedJobs($payloads); + + $this->assertTrue($this->metadata->hasChainedJobs()); + } + + public function testHasChainedJobsWithEmptyCollection(): void + { + $emptyCollection = new PayloadCollection(); + $this->metadata->setChainedJobs($emptyCollection); + + $this->assertFalse($this->metadata->hasChainedJobs()); + } + + public function testJsonSerialize(): void + { + $this->metadata->set('queue', 'default'); + $this->metadata->set('priority', 'high'); + + $json = json_encode($this->metadata); + $decoded = json_decode($json, true); + + $this->assertIsArray($decoded); + $this->assertArrayHasKey('queue', $decoded); + $this->assertArrayHasKey('priority', $decoded); + $this->assertSame('default', $decoded['queue']); + $this->assertSame('high', $decoded['priority']); + } + + public function testJsonSerializeWithChainedJobs(): void + { + $payload = new Payload('job', ['key' => 'value']); + $payload->setQueue('queue'); + + $payloads = new PayloadCollection(); + $payloads->add($payload); + + $this->metadata->setChainedJobs($payloads); + + $json = json_encode($this->metadata); + $decoded = json_decode($json, true); + + $this->assertIsArray($decoded); + $this->assertArrayHasKey('chainedJobs', $decoded); + $this->assertIsArray($decoded['chainedJobs']); + $this->assertCount(1, $decoded['chainedJobs']); + $this->assertSame('job', $decoded['chainedJobs'][0]['job']); + } + + public function testFromArray(): void + { + $data = [ + 'queue' => 'default', + 'priority' => 'high', + 'delay' => 60, + ]; + + $metadata = PayloadMetadata::fromArray($data); + + $this->assertSame('default', $metadata->get('queue')); + $this->assertSame('high', $metadata->get('priority')); + $this->assertSame(60, $metadata->get('delay')); + } + + public function testFromArrayWithChainedJobs(): void + { + $data = [ + 'queue' => 'default', + 'chainedJobs' => [ + [ + 'job' => 'job1', + 'data' => ['key1' => 'value1'], + 'metadata' => ['queue' => 'queue1'], + ], + [ + 'job' => 'job2', + 'data' => ['key2' => 'value2'], + 'metadata' => ['queue' => 'queue2'], + ], + ], + ]; + + $metadata = PayloadMetadata::fromArray($data); + + $this->assertSame('default', $metadata->get('queue')); + $this->assertTrue($metadata->hasChainedJobs()); + + $chainedJobs = $metadata->getChainedJobs(); + $this->assertInstanceOf(PayloadCollection::class, $chainedJobs); + $this->assertCount(2, $chainedJobs); + + $job1 = $chainedJobs->shift(); + $this->assertSame('job1', $job1->getJob()); + $this->assertSame(['key1' => 'value1'], $job1->getData()); + $this->assertSame('queue1', $job1->getQueue()); + } + + public function testFromArrayWithInvalidChainedJobs(): void + { + $data = [ + 'chainedJobs' => [ + ['invalid' => 'data'], // Missing job and data + [ + 'job' => 'job2', + 'data' => ['key2' => 'value2'], + ], + ], + ]; + + $metadata = PayloadMetadata::fromArray($data); + + $this->assertTrue($metadata->hasChainedJobs()); + $this->assertSame(1, $metadata->getChainedJobs()->count()); + } + + public function testToArray(): void + { + $this->metadata->set('queue', 'default'); + $this->metadata->set('priority', 'high'); + + $array = $this->metadata->toArray(); + + $this->assertArrayHasKey('queue', $array); + $this->assertArrayHasKey('priority', $array); + $this->assertSame('default', $array['queue']); + $this->assertSame('high', $array['priority']); + } +} diff --git a/tests/Payloads/PayloadTest.php b/tests/Payloads/PayloadTest.php new file mode 100644 index 0000000..570cff2 --- /dev/null +++ b/tests/Payloads/PayloadTest.php @@ -0,0 +1,295 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace App\ThirdParty\queue\tests\Payloads; + +use CodeIgniter\Queue\Exceptions\QueueException; +use CodeIgniter\Queue\Payloads\Payload; +use CodeIgniter\Queue\Payloads\PayloadCollection; +use CodeIgniter\Queue\Payloads\PayloadMetadata; +use Tests\Support\TestCase; + +/** + * @internal + */ +final class PayloadTest extends TestCase +{ + private Payload $payload; + + protected function setUp(): void + { + parent::setUp(); + $this->payload = new Payload('job', ['key' => 'value']); + } + + public function testConstructor(): void + { + $this->assertSame('job', $this->payload->getJob()); + $this->assertSame(['key' => 'value'], $this->payload->getData()); + } + + public function testConstructorWithMetadata(): void + { + $metadata = new PayloadMetadata(); + $metadata->set('priority', 'high'); + + $payload = new Payload('job', ['key' => 'value'], $metadata); + + $this->assertSame('high', $payload->getMetadata()->get('priority')); + } + + public function testGetJob(): void + { + $this->assertSame('job', $this->payload->getJob()); + } + + public function testGetData(): void + { + $this->assertSame(['key' => 'value'], $this->payload->getData()); + } + + public function testGetMetadata(): void + { + $metadata = $this->payload->getMetadata(); + + $this->assertInstanceOf(PayloadMetadata::class, $metadata); + } + + public function testSetMetadata(): void + { + $metadata = new PayloadMetadata(); + $metadata->set('priority', 'high'); + + $result = $this->payload->setMetadata($metadata); + + $this->assertInstanceOf(Payload::class, $result); + $this->assertSame('high', $this->payload->getMetadata()->get('priority')); + } + + public function testSetQueue(): void + { + $result = $this->payload->setQueue('queue'); + + $this->assertInstanceOf(Payload::class, $result); + $this->assertSame('queue', $this->payload->getQueue()); + } + + public function testSetQueueWithInvalidFormat(): void + { + $this->expectException(QueueException::class); + + $this->payload->setQueue('invalid queue name!'); + } + + public function testSetQueueWithTooLongName(): void + { + $this->expectException(QueueException::class); + + $this->payload->setQueue(str_repeat('a', 65)); // 65 characters, too long + } + + public function testGetQueue(): void + { + $this->payload->setQueue('queue'); + + $this->assertSame('queue', $this->payload->getQueue()); + } + + public function testSetPriority(): void + { + $result = $this->payload->setPriority('high'); + + $this->assertInstanceOf(Payload::class, $result); + $this->assertSame('high', $this->payload->getPriority()); + } + + public function testSetPriorityWithInvalidFormat(): void + { + $this->expectException(QueueException::class); + + $this->payload->setPriority('invalid priority!'); + } + + public function testSetPriorityWithTooLongName(): void + { + $this->expectException(QueueException::class); + + $this->payload->setPriority(str_repeat('a', 65)); // 65 characters, too long + } + + public function testGetPriority(): void + { + $this->payload->setPriority('high'); + + $this->assertSame('high', $this->payload->getPriority()); + } + + public function testSetDelay(): void + { + $result = $this->payload->setDelay(60); + + $this->assertInstanceOf(Payload::class, $result); + $this->assertSame(60, $this->payload->getDelay()); + } + + public function testSetDelayWithNegativeValue(): void + { + $this->expectException(QueueException::class); + + $this->payload->setDelay(-1); + } + + public function testGetDelay(): void + { + $this->payload->setDelay(60); + + $this->assertSame(60, $this->payload->getDelay()); + } + + public function testSetChainedJobs(): void + { + $payloads = new PayloadCollection(); + $payloads->add(new Payload('nextJob', ['nextKey' => 'nextValue'])); + + $result = $this->payload->setChainedJobs($payloads); + + $this->assertInstanceOf(Payload::class, $result); + $this->assertTrue($this->payload->hasChainedJobs()); + } + + public function testGetChainedJobs(): void + { + $payloads = new PayloadCollection(); + $payloads->add(new Payload('nextJob', ['nextKey' => 'nextValue'])); + + $this->payload->setChainedJobs($payloads); + $chainedJobs = $this->payload->getChainedJobs(); + + $this->assertInstanceOf(PayloadCollection::class, $chainedJobs); + $this->assertCount(1, $chainedJobs); + } + + public function testHasChainedJobs(): void + { + $this->assertFalse($this->payload->hasChainedJobs()); + + $payloads = new PayloadCollection(); + $payloads->add(new Payload('nextJob', ['nextKey' => 'nextValue'])); + + $this->payload->setChainedJobs($payloads); + + $this->assertTrue($this->payload->hasChainedJobs()); + } + + public function testJsonSerialize(): void + { + $this->payload->setQueue('queue'); + $this->payload->setPriority('high'); + + $json = json_encode($this->payload); + $decoded = json_decode($json, true); + + $this->assertIsArray($decoded); + $this->assertSame('job', $decoded['job']); + $this->assertSame(['key' => 'value'], $decoded['data']); + $this->assertIsArray($decoded['metadata']); + $this->assertSame('queue', $decoded['metadata']['queue']); + $this->assertSame('high', $decoded['metadata']['priority']); + } + + public function testJsonSerializeWithChainedJobs(): void + { + $this->payload->setQueue('queue'); + + $nextPayload = new Payload('nextJob', ['nextKey' => 'nextValue']); + $nextPayload->setQueue('queue'); + + $payloads = new PayloadCollection(); + $payloads->add($nextPayload); + + $this->payload->setChainedJobs($payloads); + + $json = json_encode($this->payload); + $decoded = json_decode($json, true); + + $this->assertIsArray($decoded); + $this->assertArrayHasKey('metadata', $decoded); + $this->assertArrayHasKey('chainedJobs', $decoded['metadata']); + $this->assertIsArray($decoded['metadata']['chainedJobs']); + $this->assertCount(1, $decoded['metadata']['chainedJobs']); + $this->assertSame('nextJob', $decoded['metadata']['chainedJobs'][0]['job']); + $this->assertSame(['nextKey' => 'nextValue'], $decoded['metadata']['chainedJobs'][0]['data']); + } + + public function testFromArray(): void + { + $data = [ + 'job' => 'job', + 'data' => ['key' => 'value'], + 'metadata' => [ + 'queue' => 'queue', + 'priority' => 'high', + ], + ]; + + $payload = Payload::fromArray($data); + + $this->assertSame('job', $payload->getJob()); + $this->assertSame(['key' => 'value'], $payload->getData()); + $this->assertSame('queue', $payload->getQueue()); + $this->assertSame('high', $payload->getPriority()); + } + + public function testFromArrayWithChainedJobs(): void + { + $data = [ + 'job' => 'job', + 'data' => ['key' => 'value'], + 'metadata' => [ + 'queue' => 'queue', + 'chainedJobs' => [ + [ + 'job' => 'nextJob', + 'data' => ['nextKey' => 'nextValue'], + 'metadata' => ['queue' => 'nextQueue'], + ], + ], + ], + ]; + + $payload = Payload::fromArray($data); + + $this->assertTrue($payload->hasChainedJobs()); + $chainedJobs = $payload->getChainedJobs(); + $this->assertCount(1, $chainedJobs); + + $nextJob = $chainedJobs->shift(); + $this->assertSame('nextJob', $nextJob->getJob()); + $this->assertSame(['nextKey' => 'nextValue'], $nextJob->getData()); + $this->assertSame('nextQueue', $nextJob->getQueue()); + } + + public function testMultipleValidations(): void + { + $payload = new Payload('job', ['key' => 'value']); + + // Test that all validations pass + $payload->setQueue('valid-queue'); + $payload->setPriority('valid-priority'); + $payload->setDelay(30); + + $this->assertSame('valid-queue', $payload->getQueue()); + $this->assertSame('valid-priority', $payload->getPriority()); + $this->assertSame(30, $payload->getDelay()); + } +} diff --git a/tests/PredisHandlerTest.php b/tests/PredisHandlerTest.php index 148adea..226732b 100644 --- a/tests/PredisHandlerTest.php +++ b/tests/PredisHandlerTest.php @@ -81,6 +81,7 @@ public function testPush(): void $queueJob = new QueueJob(json_decode((string) $task[0], true)); $this->assertSame('success', $queueJob->payload['job']); $this->assertSame(['key' => 'value'], $queueJob->payload['data']); + $this->assertSame([], $queueJob->payload['metadata']); } /** @@ -100,6 +101,7 @@ public function testPushWithPriority(): void $queueJob = new QueueJob(json_decode((string) $task[0], true)); $this->assertSame('success', $queueJob->payload['job']); $this->assertSame(['key' => 'value'], $queueJob->payload['data']); + $this->assertSame([], $queueJob->payload['metadata']); } /** @@ -121,6 +123,92 @@ public function testPushWithDelay(): void $queueJob = new QueueJob(json_decode((string) $task[0], true)); $this->assertSame('success', $queueJob->payload['job']); $this->assertSame(['key' => 'value'], $queueJob->payload['data']); + $this->assertSame([], $queueJob->payload['metadata']); + } + + /** + * @throws Exception + */ + public function testChain(): void + { + Time::setTestNow('2023-12-29 14:15:16'); + + $handler = new PredisHandler($this->config); + $result = $handler->chain(static function ($chain): void { + $chain + ->push('queue', 'success', ['key1' => 'value1']) + ->push('queue', 'success', ['key2' => 'value2']); + }); + + $this->assertTrue($result); + + $predis = self::getPrivateProperty($handler, 'predis'); + $this->assertSame(1, $predis->zcard('queues:queue:low')); + + $task = $predis->zrangebyscore('queues:queue:low', '-inf', Time::now()->timestamp, ['limit' => [0, 1]]); + $job = new QueueJob(json_decode((string) $task[0], true)); + + $this->assertSame('success', $job->payload['job']); + $this->assertSame(['key1' => 'value1'], $job->payload['data']); + $this->assertArrayHasKey('metadata', $job->payload); + $this->assertArrayHasKey('queue', $job->payload['metadata']); + $this->assertSame('queue', $job->payload['metadata']['queue']); + $this->assertArrayHasKey('chainedJobs', $job->payload['metadata']); + + $chainedJobs = $job->payload['metadata']['chainedJobs']; + $this->assertCount(1, $chainedJobs); + $this->assertSame('success', $chainedJobs[0]['job']); + $this->assertSame(['key2' => 'value2'], $chainedJobs[0]['data']); + $this->assertSame('queue', $chainedJobs[0]['metadata']['queue']); + } + + /** + * @throws Exception + */ + public function testChainWithPriorityAndDelay(): void + { + Time::setTestNow('2023-12-29 14:15:16'); + + $handler = new PredisHandler($this->config); + $result = $handler->chain(static function ($chain): void { + $chain + ->push('queue', 'success', ['key1' => 'value1']) + ->setPriority('high') + ->setDelay(60) + ->push('queue', 'success', ['key2' => 'value2']) + ->setPriority('low') + ->setDelay(120); + }); + + $this->assertTrue($result); + + $predis = self::getPrivateProperty($handler, 'predis'); + // Should be in high priority queue + $this->assertSame(1, $predis->zcard('queues:queue:high')); + + // Check with delay + $task = $predis->zrangebyscore('queues:queue:high', '-inf', Time::now()->addSeconds(61)->timestamp, ['limit' => [0, 1]]); + $queueJob = new QueueJob(json_decode((string) $task[0], true)); + + $this->assertSame('success', $queueJob->payload['job']); + $this->assertSame(['key1' => 'value1'], $queueJob->payload['data']); + $this->assertArrayHasKey('metadata', $queueJob->payload); + + // Check metadata + $meta = $queueJob->payload['metadata']; + $this->assertSame('queue', $meta['queue']); + $this->assertSame('high', $meta['priority']); + $this->assertSame(60, $meta['delay']); + + // Check a chained job with its priority and delay + $this->assertArrayHasKey('chainedJobs', $meta); + $chainedJobs = $meta['chainedJobs']; + $this->assertCount(1, $chainedJobs); + $this->assertSame('success', $chainedJobs[0]['job']); + $this->assertSame(['key2' => 'value2'], $chainedJobs[0]['data']); + $this->assertSame('queue', $chainedJobs[0]['metadata']['queue']); + $this->assertSame('low', $chainedJobs[0]['metadata']['priority']); + $this->assertSame(120, $chainedJobs[0]['metadata']['delay']); } public function testPushException(): void diff --git a/tests/PushAndPopWithDelayTest.php b/tests/PushAndPopWithDelayTest.php index cb2d0c1..db49b81 100644 --- a/tests/PushAndPopWithDelayTest.php +++ b/tests/PushAndPopWithDelayTest.php @@ -73,20 +73,20 @@ public function testPushAndPopWithDelay(string $name, string $class): void if ($name === 'database') { $this->seeInDatabase('queue_jobs', [ 'queue' => 'queue-delay', - 'payload' => json_encode(['job' => 'success', 'data' => ['key1' => 'value1']]), + 'payload' => json_encode(['job' => 'success', 'data' => ['key1' => 'value1'], 'metadata' => []]), 'available_at' => 1703859376, ]); $this->seeInDatabase('queue_jobs', [ 'queue' => 'queue-delay', - 'payload' => json_encode(['job' => 'success', 'data' => ['key2' => 'value2']]), + 'payload' => json_encode(['job' => 'success', 'data' => ['key2' => 'value2'], 'metadata' => []]), 'available_at' => 1703859316, ]); } $result = $handler->pop('queue-delay', ['default']); $this->assertInstanceOf(QueueJob::class, $result); - $payload = ['job' => 'success', 'data' => ['key2' => 'value2']]; + $payload = ['job' => 'success', 'data' => ['key2' => 'value2'], 'metadata' => []]; $this->assertSame($payload, $result->payload); $result = $handler->pop('queue-delay', ['default']); @@ -97,7 +97,7 @@ public function testPushAndPopWithDelay(string $name, string $class): void $result = $handler->pop('queue-delay', ['default']); $this->assertInstanceOf(QueueJob::class, $result); - $payload = ['job' => 'success', 'data' => ['key1' => 'value1']]; + $payload = ['job' => 'success', 'data' => ['key1' => 'value1'], 'metadata' => []]; $this->assertSame($payload, $result->payload); } } diff --git a/tests/RedisHandlerTest.php b/tests/RedisHandlerTest.php index 57178eb..aa71240 100644 --- a/tests/RedisHandlerTest.php +++ b/tests/RedisHandlerTest.php @@ -78,6 +78,7 @@ public function testPush(): void $queueJob = new QueueJob(json_decode((string) $task[0], true)); $this->assertSame('success', $queueJob->payload['job']); $this->assertSame(['key' => 'value'], $queueJob->payload['data']); + $this->assertSame([], $queueJob->payload['metadata']); } public function testPushWithPriority(): void @@ -94,6 +95,7 @@ public function testPushWithPriority(): void $queueJob = new QueueJob(json_decode((string) $task[0], true)); $this->assertSame('success', $queueJob->payload['job']); $this->assertSame(['key' => 'value'], $queueJob->payload['data']); + $this->assertSame([], $queueJob->payload['metadata']); } /** @@ -115,6 +117,92 @@ public function testPushWithDelay(): void $queueJob = new QueueJob(json_decode((string) $task[0], true)); $this->assertSame('success', $queueJob->payload['job']); $this->assertSame(['key' => 'value'], $queueJob->payload['data']); + $this->assertSame([], $queueJob->payload['metadata']); + } + + /** + * @throws Exception + */ + public function testChain(): void + { + Time::setTestNow('2023-12-29 14:15:16'); + + $handler = new RedisHandler($this->config); + $result = $handler->chain(static function ($chain): void { + $chain + ->push('queue', 'success', ['key1' => 'value1']) + ->push('queue', 'success', ['key2' => 'value2']); + }); + + $this->assertTrue($result); + + $redis = self::getPrivateProperty($handler, 'redis'); + $this->assertSame(1, $redis->zCard('queues:queue:low')); + + $task = $redis->zRangeByScore('queues:queue:low', '-inf', Time::now()->timestamp, ['limit' => [0, 1]]); + $queueJob = new QueueJob(json_decode((string) $task[0], true)); + + $this->assertSame('success', $queueJob->payload['job']); + $this->assertSame(['key1' => 'value1'], $queueJob->payload['data']); + $this->assertArrayHasKey('metadata', $queueJob->payload); + $this->assertArrayHasKey('queue', $queueJob->payload['metadata']); + $this->assertSame('queue', $queueJob->payload['metadata']['queue']); + $this->assertArrayHasKey('chainedJobs', $queueJob->payload['metadata']); + + $chainedJobs = $queueJob->payload['metadata']['chainedJobs']; + $this->assertCount(1, $chainedJobs); + $this->assertSame('success', $chainedJobs[0]['job']); + $this->assertSame(['key2' => 'value2'], $chainedJobs[0]['data']); + $this->assertSame('queue', $chainedJobs[0]['metadata']['queue']); + } + + /** + * @throws Exception + */ + public function testChainWithPriorityAndDelay(): void + { + Time::setTestNow('2023-12-29 14:15:16'); + + $handler = new RedisHandler($this->config); + $result = $handler->chain(static function ($chain): void { + $chain + ->push('queue', 'success', ['key1' => 'value1']) + ->setPriority('high') + ->setDelay(60) + ->push('queue', 'success', ['key2' => 'value2']) + ->setPriority('low') + ->setDelay(120); + }); + + $this->assertTrue($result); + + $redis = self::getPrivateProperty($handler, 'redis'); + // Should be in high priority queue + $this->assertSame(1, $redis->zCard('queues:queue:high')); + + // Check with delay + $task = $redis->zRangeByScore('queues:queue:high', '-inf', Time::now()->addSeconds(61)->timestamp, ['limit' => [0, 1]]); + $queueJob = new QueueJob(json_decode((string) $task[0], true)); + + $this->assertSame('success', $queueJob->payload['job']); + $this->assertSame(['key1' => 'value1'], $queueJob->payload['data']); + $this->assertArrayHasKey('metadata', $queueJob->payload); + + // Check metadata + $metadata = $queueJob->payload['metadata']; + $this->assertSame('queue', $metadata['queue']); + $this->assertSame('high', $metadata['priority']); + $this->assertSame(60, $metadata['delay']); + + // Check a chained job with its priority and delay + $this->assertArrayHasKey('chainedJobs', $metadata); + $chainedJobs = $metadata['chainedJobs']; + $this->assertCount(1, $chainedJobs); + $this->assertSame('success', $chainedJobs[0]['job']); + $this->assertSame(['key2' => 'value2'], $chainedJobs[0]['data']); + $this->assertSame('queue', $chainedJobs[0]['metadata']['queue']); + $this->assertSame('low', $chainedJobs[0]['metadata']['priority']); + $this->assertSame(120, $chainedJobs[0]['metadata']['delay']); } public function testPushException(): void