diff --git a/composer.json b/composer.json index a59d9b1..048c932 100644 --- a/composer.json +++ b/composer.json @@ -21,10 +21,11 @@ "source": "https://github.com/cakephp/queue" }, "require": { - "php": ">=8.1", - "cakephp/cakephp": "^5.1.0", + "php": ">=8.2", + "cakephp/cakephp": "^5.4.0", "enqueue/simple-client": "^0.10", - "psr/log": "^3.0" + "psr/log": "^3.0", + "ramsey/uuid": "^4.7.0" }, "require-dev": { "cakephp/bake": "^3.5.1", diff --git a/docs/en/jobs.md b/docs/en/jobs.md index e61698c..d6a3666 100644 --- a/docs/en/jobs.md +++ b/docs/en/jobs.md @@ -60,7 +60,7 @@ Returning any other value is treated as a failure and results in the message bei ## Job Properties - `maxAttempts` limits how many times a job can be retried after an exception or explicit `Processor::REQUEUE`. If unset, the worker's `--max-attempts` option applies. If neither is set, retries are unlimited. -- `shouldBeUnique` allows only one queued copy of the same job class, method, and payload. Duplicate pushes are ignored. This requires `uniqueCache` in the queue configuration. +- `shouldBeUnique` allows only one queued copy of the same job class, method, and payload. Duplicate pushes are ignored. This requires `uniqueCache` in the queue configuration. When the payload is a DTO, its class is also factored into the uniqueness check, so two different DTO classes with coincidentally identical data are never treated as duplicates of each other. ## Queueing Jobs @@ -89,3 +89,69 @@ Supported options: - `expires`: expire the message after a number of seconds if it has not been consumed. - `priority`: one of `\Enqueue\Client\MessagePriority::VERY_LOW`, `LOW`, `NORMAL`, `HIGH`, or `VERY_HIGH`. - `queue`: queue name to use. Defaults to the configured queue, then `default`. + +## Dispatching and Receiving DTOs + +Instead of an array, `QueueManager::push()` also accepts a DTO object as the payload: + +```php +use App\Dto\OrderDto; +use App\Job\ProcessOrderJob; +use Cake\Queue\QueueManager; + +$order = new OrderDto(id: 7, customer: 'Acme Corp'); + +QueueManager::push(ProcessOrderJob::class, $order); +``` + +The DTO is serialized into the same JSON-safe array that a plain array payload would produce (via `jsonSerialize()` when the DTO implements `JsonSerializable`, otherwise its public properties). The DTO's class name is also recorded on the message as metadata (used for `shouldBeUnique` hashing and debugging). If you only have an array at the dispatch site but still want that metadata recorded, pass the class via the `dtoClass` option: + +```php +QueueManager::push(ProcessOrderJob::class, $data, [ + 'dtoClass' => OrderDto::class, +]); +``` + +A plain array push with no `dtoClass` option behaves exactly as before; the message body is unchanged. + +### Receiving a DTO in a job + +Call `Message::getDto()` with the class your job expects. The expected type comes from your code, not from the message body — that way a tampered queue message cannot choose which class gets instantiated. `getArgument()` keeps returning the raw array: + +```php +public function execute(Message $message): ?string +{ + $order = $message->getDto(OrderDto::class); + $id = $message->getArgument('id'); // the raw array is still available + + return Processor::ACK; +} +``` + +If the payload cannot be hydrated into the given class, `getDto()` throws. Jobs that still need to accept legacy array-only messages can catch that exception (or keep using `getArgument()` only) while they migrate. + +### Supported DTO classes + +Hydration mirrors the DTO conventions used elsewhere in CakePHP (`#[RequestToDto]` for controllers, `SelectQuery::projectAs()` for the ORM), so the same DTO class can be reused across all three: + +- **Constructor reflection** — a plain class (typically `readonly`) with typed, named constructor parameters. Nested DTOs are resolved from the parameter's type hint, and arrays of DTOs via the `#[CollectionOf]` attribute: + + ```php + use Cake\ORM\Attribute\CollectionOf; + + readonly class OrderDto + { + /** + * @param array $items + */ + public function __construct( + public int $id, + public string $customer, + #[CollectionOf(OrderItemDto::class)] + public array $items = [], + ) { + } + } + ``` + +- **`createFromArray()` factory** — if the DTO class defines a static `createFromArray(array $data, bool $nested = false): static` method, it's used instead of reflection. diff --git a/src/Consumption/LimitAttemptsExtension.php b/src/Consumption/LimitAttemptsExtension.php index 721431e..2733470 100644 --- a/src/Consumption/LimitAttemptsExtension.php +++ b/src/Consumption/LimitAttemptsExtension.php @@ -12,9 +12,6 @@ class LimitAttemptsExtension implements MessageResultExtensionInterface { - /** - * @use \Cake\Event\EventDispatcherTrait<\Cake\Queue\Job\Message> - */ use EventDispatcherTrait; /** diff --git a/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php b/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php index 3a70e17..8b9dc5e 100644 --- a/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php +++ b/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php @@ -37,7 +37,7 @@ public function onResult(MessageResult $context): void $data = $jobMessage->getArgument(); - $uniqueId = QueueManager::getUniqueId($class, $method, $data); + $uniqueId = QueueManager::getUniqueId($class, $method, $data, $jobMessage->getDtoClass()); Cache::delete($uniqueId, $this->cache); } diff --git a/src/Dto/DtoManager.php b/src/Dto/DtoManager.php new file mode 100644 index 0000000..56bb070 --- /dev/null +++ b/src/Dto/DtoManager.php @@ -0,0 +1,106 @@ +|object $data Data or DTO object to serialize. + * @return array Serialized data. + */ + public static function serialize(array|object $data): array + { + if ($data instanceof JsonSerializable) { + $data = $data->jsonSerialize(); + } + + if (is_object($data)) { + $data = get_object_vars($data); + } + + if (!is_array($data)) { + throw new InvalidArgumentException( + 'DTO data could not be serialized into an array. `jsonSerialize()` must return an array or object.', + ); + } + + return self::toScalarArray($data); + } + + /** + * Hydrate queue data back into a DTO instance. + * + * This method is not safe to use with user-defined `dtoClass` values. + * @template T of object + * @param array $data Serialized data. + * @param class-string $dtoClass DTO class name. + * @return T Hydrated DTO instance. + * @throws \InvalidArgumentException When the DTO class does not exist. + */ + public static function deserialize(array $data, string $dtoClass): object + { + if (!class_exists($dtoClass)) { + throw new InvalidArgumentException(sprintf('DTO class `%s` does not exist.', $dtoClass)); + } + + $dto = (new ResultSetFactory())->hydrateDto($data, $dtoClass); + assert($dto instanceof $dtoClass); + + return $dto; + } + + /** + * Recursively convert any nested objects into arrays. + * + * @param array $data The data to convert. + * @return array The converted data. + */ + protected static function toScalarArray(array $data): array + { + foreach ($data as $key => $value) { + if (is_object($value)) { + $value = $value instanceof JsonSerializable + ? $value->jsonSerialize() + : get_object_vars($value); + } + + if (is_array($value)) { + $data[$key] = self::toScalarArray($value); + } + } + + return $data; + } +} diff --git a/src/Job/JobInterface.php b/src/Job/JobInterface.php index 65f84fc..d3dd2b8 100644 --- a/src/Job/JobInterface.php +++ b/src/Job/JobInterface.php @@ -22,7 +22,6 @@ interface JobInterface * Executes logic for Job * * @param \Cake\Queue\Job\Message $message job message - * @return string|null */ public function execute(Message $message): ?string; } diff --git a/src/Job/MailerJob.php b/src/Job/MailerJob.php index 3d6d71e..81d167d 100644 --- a/src/Job/MailerJob.php +++ b/src/Job/MailerJob.php @@ -29,7 +29,6 @@ class MailerJob implements JobInterface * Constructs and dispatches the event from a job message * * @param \Cake\Queue\Job\Message $message job message - * @return string|null */ public function execute(Message $message): ?string { diff --git a/src/Job/Message.php b/src/Job/Message.php index f1b504c..521351d 100644 --- a/src/Job/Message.php +++ b/src/Job/Message.php @@ -17,10 +17,12 @@ namespace Cake\Queue\Job; use Cake\Core\ContainerInterface; +use Cake\Queue\Dto\DtoManager; use Cake\Utility\Hash; use Closure; use Interop\Queue\Context; use Interop\Queue\Message as QueueMessage; +use InvalidArgumentException; use JsonSerializable; use RuntimeException; @@ -33,6 +35,13 @@ class Message implements JsonSerializable protected ?Closure $callable = null; + protected ?object $dto = null; + + /** + * @var class-string|null + */ + protected ?string $dtoHydratedAs = null; + /** * @param \Interop\Queue\Message $originalMessage Queue message. * @param \Interop\Queue\Context $context Context. @@ -126,7 +135,6 @@ public function getTarget(): array /** * @param mixed $key Key * @param mixed $default Default value. - * @return mixed */ public function getArgument(mixed $key = null, mixed $default = null): mixed { @@ -146,9 +154,58 @@ public function getArgument(mixed $key = null, mixed $default = null): mixed } /** - * The maximum number of attempts allowed by the job. + * Get the DTO class name recorded on the message body at dispatch time, if any. + * + * This value is metadata for uniqueness hashing and debugging. It is never used + * as the hydration target — pass the expected class to `getDto()` instead. + * + * @return class-string|null + */ + public function getDtoClass(): ?string + { + $dtoClass = $this->parsedBody['dtoClass'] ?? null; + if (!is_string($dtoClass) || !class_exists($dtoClass)) { + return null; + } + + return $dtoClass; + } + + /** + * Hydrate the message data into the expected DTO class. * - * @return int|null + * The class name must come from application code, not from the message body. + * That keeps queue consumers safe if a message is tampered with: only the type + * the job asks for is ever instantiated. + * + * @template T of object + * @param class-string $dtoClass The DTO class the job expects. + * @return T + * @throws \InvalidArgumentException When `$dtoClass` does not exist or cannot be hydrated. + */ + public function getDto(string $dtoClass): object + { + if ($this->dto !== null && $this->dtoHydratedAs === $dtoClass) { + assert($this->dto instanceof $dtoClass); + + return $this->dto; + } + + if (!class_exists($dtoClass)) { + throw new InvalidArgumentException(sprintf('DTO class `%s` does not exist.', $dtoClass)); + } + + $dto = DtoManager::deserialize($this->getArgument(), $dtoClass); + assert($dto instanceof $dtoClass); + + $this->dto = $dto; + $this->dtoHydratedAs = $dtoClass; + + return $dto; + } + + /** + * The maximum number of attempts allowed by the job. */ public function getMaxAttempts(): ?int { diff --git a/src/Queue/Processor.php b/src/Queue/Processor.php index bff6628..5a66ae5 100644 --- a/src/Queue/Processor.php +++ b/src/Queue/Processor.php @@ -31,9 +31,6 @@ class Processor implements InteropProcessor { - /** - * @use \Cake\Event\EventDispatcherTrait<\Cake\Queue\Queue\Processor> - */ use EventDispatcherTrait; /** diff --git a/src/QueueManager.php b/src/QueueManager.php index 9a8bd89..1e45c4f 100644 --- a/src/QueueManager.php +++ b/src/QueueManager.php @@ -20,6 +20,7 @@ use Cake\Cache\Cache; use Cake\Core\App; use Cake\Log\Log; +use Cake\Queue\Dto\DtoManager; use Enqueue\Client\Message as ClientMessage; use Enqueue\SimpleClient\SimpleClient; use InvalidArgumentException; @@ -205,11 +206,18 @@ public static function engine(string $name): SimpleClient * @param array|string $className The classname of a job that implements the * \Cake\Queue\Job\JobInterface. The class will be constructed by * \Cake\Queue\Processor and have the execute method invoked. - * @param array $data An array of data that will be passed to the job. + * @param array|object $data An array of data or a DTO object that will + * be passed to the job. When a DTO object is given it is serialized and the class + * name is stored as message metadata (uniqueness / debugging). Jobs must still + * pass the expected class to `Message::getDto()`. * @param array $options An array of options for publishing the job: * - `config` - A queue config name. Defaults to 'default'. * - `delay` - Time (in integer seconds) to delay message, after which it * will be processed. Not all message brokers accept this. Default `null`. + * - `dtoClass` - Optional DTO class metadata recorded on the message body. + * Only needed when `$data` is an array. Ignored when `$data` is already a DTO + * object. Does not control hydration — the job passes the expected class to + * `Message::getDto()`. Default `null`. * - `expires` - Time (in integer seconds) after which the message expires. * The message will be removed from the queue if this time is exceeded * and it has not been consumed. Default `null`. @@ -222,7 +230,7 @@ public static function engine(string $name): SimpleClient * - `queue` - The name of a queue to use, from queue `config` array or * string 'default' if empty. */ - public static function push(string|array $className, array $data = [], array $options = []): void + public static function push(string|array $className, array|object $data = [], array $options = []): void { [$class, $method] = is_array($className) ? $className : [$className, 'execute']; @@ -231,6 +239,15 @@ public static function push(string|array $className, array $data = [], array $op throw new InvalidArgumentException(sprintf('`%s` class does not exist.', $class)); } + $dtoClass = null; + if (is_object($data)) { + $dtoClass = $data::class; + $data = DtoManager::serialize($data); + } elseif (!empty($options['dtoClass'])) { + $dtoClass = $options['dtoClass']; + $data = DtoManager::serialize($data); + } + $name = $options['config'] ?? 'default'; $config = static::getConfig($name) + [ @@ -246,7 +263,7 @@ public static function push(string|array $className, array $data = [], array $op ); } - $uniqueId = static::getUniqueId($class, $method, $data); + $uniqueId = static::getUniqueId($class, $method, $data, $dtoClass); if (Cache::read($uniqueId, $config['uniqueCacheKey'])) { if ($logger instanceof LoggerInterface) { @@ -264,7 +281,7 @@ public static function push(string|array $className, array $data = [], array $op $queue = $options['queue'] ?? $config['queue'] ?? 'default'; - $message = new ClientMessage([ + $body = [ 'class' => [$class, $method], 'args' => [$data], 'data' => $data, @@ -273,7 +290,12 @@ public static function push(string|array $className, array $data = [], array $op 'priority' => $options['priority'] ?? null, 'queue' => $queue, ], - ]); + ]; + if ($dtoClass !== null) { + $body['dtoClass'] = $dtoClass; + } + + $message = new ClientMessage($body); if (isset($options['delay'])) { $message->setDelay($options['delay']); @@ -291,7 +313,7 @@ public static function push(string|array $className, array $data = [], array $op $client->sendEvent($queue, $message); if (!empty($class::$shouldBeUnique)) { - $uniqueId = static::getUniqueId($class, $method, $data); + $uniqueId = static::getUniqueId($class, $method, $data, $dtoClass); Cache::add($uniqueId, true, $config['uniqueCacheKey']); } @@ -301,14 +323,18 @@ public static function push(string|array $className, array $data = [], array $op * @param class-string $class Class name * @param string $method Method name * @param array $data Message data + * @param class-string|null $dtoClass The DTO class the data was dispatched with, if any. Two + * dispatches with identical `$data` but different `$dtoClass` are treated as distinct so a + * coincidental structural match between unrelated DTOs does not collapse into one dedupe entry. */ - public static function getUniqueId(string $class, string $method, array $data): string + public static function getUniqueId(string $class, string $method, array $data, ?string $dtoClass = null): string { $data = static::sortUniqueValues($data); $hashInput = implode('', [ $class, $method, + $dtoClass ?? '', json_encode($data), ]); diff --git a/src/TestSuite/Transport/TestConsumer.php b/src/TestSuite/Transport/TestConsumer.php index ca2bf70..00db46e 100644 --- a/src/TestSuite/Transport/TestConsumer.php +++ b/src/TestSuite/Transport/TestConsumer.php @@ -43,7 +43,6 @@ public function getQueue(): Queue * Receive message * * @param int|null $timeout Timeout in milliseconds - * @return \Interop\Queue\Message|null */ public function receive(?int $timeout = null): ?Message { @@ -52,8 +51,6 @@ public function receive(?int $timeout = null): ?Message /** * Receive no wait - * - * @return \Interop\Queue\Message|null */ public function receiveNoWait(): ?Message { diff --git a/src/TestSuite/Transport/TestMessage.php b/src/TestSuite/Transport/TestMessage.php index 2eaa341..d9904a4 100644 --- a/src/TestSuite/Transport/TestMessage.php +++ b/src/TestSuite/Transport/TestMessage.php @@ -83,7 +83,6 @@ public function setProperty(string $name, mixed $value): void * * @param string $name Property name * @param mixed $default Default value - * @return mixed */ public function getProperty(string $name, mixed $default = null): mixed { @@ -107,7 +106,6 @@ public function setHeader(string $name, mixed $value): void * * @param string $name Header name * @param mixed $default Default value - * @return mixed */ public function getHeader(string $name, mixed $default = null): mixed { @@ -178,8 +176,6 @@ public function setRedelivered(bool $redelivered): void /** * Get correlation ID - * - * @return string|null */ public function getCorrelationId(): ?string { @@ -199,8 +195,6 @@ public function setCorrelationId(?string $correlationId = null): void /** * Get message ID - * - * @return string|null */ public function getMessageId(): ?string { @@ -220,8 +214,6 @@ public function setMessageId(?string $messageId = null): void /** * Get timestamp - * - * @return int|null */ public function getTimestamp(): ?int { @@ -241,8 +233,6 @@ public function setTimestamp(?int $timestamp = null): void /** * Get reply to - * - * @return string|null */ public function getReplyTo(): ?string { diff --git a/src/TestSuite/Transport/TestProducer.php b/src/TestSuite/Transport/TestProducer.php index 75b433f..2100ec0 100644 --- a/src/TestSuite/Transport/TestProducer.php +++ b/src/TestSuite/Transport/TestProducer.php @@ -63,8 +63,6 @@ public function setDeliveryDelay(?int $deliveryDelay = null): Producer /** * Get delivery delay - * - * @return int|null */ public function getDeliveryDelay(): ?int { @@ -86,8 +84,6 @@ public function setPriority(?int $priority = null): Producer /** * Get priority - * - * @return int|null */ public function getPriority(): ?int { @@ -109,8 +105,6 @@ public function setTimeToLive(?int $timeToLive = null): Producer /** * Get time to live - * - * @return int|null */ public function getTimeToLive(): ?int { diff --git a/templates/bake/job.twig b/templates/bake/job.twig index 7dc6873..7c32b34 100644 --- a/templates/bake/job.twig +++ b/templates/bake/job.twig @@ -49,7 +49,6 @@ class {{ name }}Job implements JobInterface * Executes logic for {{ name }}Job * * @param \Cake\Queue\Job\Message $message job message - * @return string|null */ public function execute(Message $message): ?string { diff --git a/tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php b/tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php index 05b940e..c3f5175 100644 --- a/tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php +++ b/tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php @@ -15,6 +15,7 @@ use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\BeforeClass; use Psr\Log\NullLogger; +use TestApp\Dto\OrderDto; use TestApp\Job\UniqueJob; class RemoveUniqueJobIdFromCacheExtensionTest extends TestCase @@ -26,9 +27,9 @@ public static function dropConfigs() { Log::drop('debug'); + $cacheKey = QueueManager::getConfig('default')['uniqueCacheKey'] ?? null; QueueManager::drop('default'); - $cacheKey = QueueManager::getConfig('default')['uniqueCacheKey'] ?? null; if ($cacheKey) { Cache::clear($cacheKey); Cache::drop($cacheKey); @@ -49,6 +50,32 @@ public function testJobIsRemovedFromCacheAfterProcessing() $this->assertNull(Cache::read($uniqueId, 'Cake/Queue.queueUnique.default')); } + /** + * Test that a unique job dispatched with a DTO is removed from the cache + * using a hash that includes the dtoClass, matching the one computed at push time. + * + * @return void + */ + public function testJobWithDtoIsRemovedFromCacheAfterProcessing() + { + $consume = $this->setupQueue(); + + $dto = new OrderDto(7, 'Acme Corp', []); + QueueManager::push(UniqueJob::class, $dto); + + $uniqueId = QueueManager::getUniqueId( + UniqueJob::class, + 'execute', + ['id' => 7, 'customer' => 'Acme Corp', 'items' => []], + OrderDto::class, + ); + $this->assertTrue(Cache::read($uniqueId, 'Cake/Queue.queueUnique.default')); + + $consume(); + + $this->assertNull(Cache::read($uniqueId, 'Cake/Queue.queueUnique.default')); + } + protected function setupQueue() { Log::setConfig('debug', [ diff --git a/tests/TestCase/Dto/DtoManagerTest.php b/tests/TestCase/Dto/DtoManagerTest.php new file mode 100644 index 0000000..6ddbc21 --- /dev/null +++ b/tests/TestCase/Dto/DtoManagerTest.php @@ -0,0 +1,174 @@ + 1, 'nested' => ['a' => 'b']]; + + $this->assertSame($data, DtoManager::serialize($data)); + } + + /** + * Test that a plain object is serialized from its public properties. + * + * @return void + */ + public function testSerializeObject() + { + $object = new class (1, 'Acme') { + public function __construct( + public int $id, + public string $name, + ) { + } + }; + + $this->assertSame(['id' => 1, 'name' => 'Acme'], DtoManager::serialize($object)); + } + + /** + * Test that JsonSerializable DTOs use their jsonSerialize() output. + * + * @return void + */ + public function testSerializeJsonSerializable() + { + $dto = new JsonSerializableDto(1, 'acme'); + + $this->assertSame(['id' => 1, 'label' => 'ACME'], DtoManager::serialize($dto)); + } + + /** + * Test that a JsonSerializable DTO returning a non-array/non-object value + * from jsonSerialize() throws a clear exception instead of an unrelated + * TypeError from get_object_vars(). + * + * @return void + */ + public function testSerializeJsonSerializableReturningScalarThrows() + { + $dto = new ScalarJsonSerializableDto('not-an-array'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('could not be serialized into an array'); + + DtoManager::serialize($dto); + } + + /** + * Test that nested objects are recursively converted to arrays. + * + * @return void + */ + public function testSerializeNestedObjects() + { + $dto = new OrderDto(7, 'Acme', [new OrderItemDto('SKU-1', 2)]); + + $this->assertSame([ + 'id' => 7, + 'customer' => 'Acme', + 'items' => [ + ['sku' => 'SKU-1', 'quantity' => 2], + ], + ], DtoManager::serialize($dto)); + } + + /** + * Test hydration using a createFromArray() factory method. + * + * @return void + */ + public function testDeserializeWithCreateFromArray() + { + $dto = DtoManager::deserialize([ + 'id' => 3, + 'username' => 'markstory', + ], UserDto::class); + + $this->assertInstanceOf(UserDto::class, $dto); + $this->assertSame(3, $dto->id); + $this->assertSame('markstory', $dto->username); + } + + /** + * Test hydration of a plain DTO using constructor reflection. + * + * @return void + */ + public function testDeserializeWithReflection() + { + $dto = DtoManager::deserialize([ + 'id' => 7, + 'customer' => 'Acme', + 'items' => [ + ['sku' => 'SKU-1', 'quantity' => 2], + ], + ], OrderDto::class); + + $this->assertInstanceOf(OrderDto::class, $dto); + $this->assertSame(7, $dto->id); + $this->assertSame('Acme', $dto->customer); + $this->assertCount(1, $dto->items); + $this->assertInstanceOf(OrderItemDto::class, $dto->items[0]); + $this->assertSame('SKU-1', $dto->items[0]->sku); + $this->assertSame(2, $dto->items[0]->quantity); + } + + /** + * Test that a non-existent DTO class throws. + * + * @return void + */ + public function testDeserializeNonExistentClass() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('does not exist'); + + DtoManager::deserialize(['id' => 1], 'TestApp\Dto\DoesNotExist'); + } + + /** + * Test that a DTO with no factory and an incompatible constructor throws. + * + * @return void + */ + public function testDeserializeIncompatibleDto() + { + $this->expectException(ArgumentCountError::class); + + DtoManager::deserialize(['id' => 1], InvalidDto::class); + } +} diff --git a/tests/TestCase/Job/MessageTest.php b/tests/TestCase/Job/MessageTest.php index 9d09471..d8c384e 100644 --- a/tests/TestCase/Job/MessageTest.php +++ b/tests/TestCase/Job/MessageTest.php @@ -22,7 +22,11 @@ use Enqueue\Null\NullConnectionFactory; use Enqueue\Null\NullMessage; use Error; +use InvalidArgumentException; use RuntimeException; +use TestApp\Dto\OrderDto; +use TestApp\Dto\OrderItemDto; +use TestApp\Dto\UserDto; use TestApp\WelcomeMailer; class MessageTest extends TestCase @@ -94,6 +98,156 @@ public function testLegacyArguments() $this->assertSame('no third argument', $message->getArgument('third', 'no third argument')); } + /** + * Test that a DTO is hydrated into the class the job asks for. + * + * @return void + */ + public function testGetDto() + { + $parsedBody = [ + 'class' => [WelcomeMailer::class, 'welcome'], + 'data' => [ + 'id' => 7, + 'customer' => 'Acme Corp', + 'items' => [ + ['sku' => 'SKU-1', 'quantity' => 2], + ['sku' => 'SKU-2', 'quantity' => 1], + ], + ], + 'dtoClass' => OrderDto::class, + ]; + $connectionFactory = new NullConnectionFactory(); + $context = $connectionFactory->createContext(); + $originalMessage = new NullMessage((string)json_encode($parsedBody)); + $message = new Message($originalMessage, $context); + + $this->assertSame(OrderDto::class, $message->getDtoClass()); + + $dto = $message->getDto(OrderDto::class); + $this->assertInstanceOf(OrderDto::class, $dto); + $this->assertSame(7, $dto->id); + $this->assertSame('Acme Corp', $dto->customer); + $this->assertCount(2, $dto->items); + $this->assertInstanceOf(OrderItemDto::class, $dto->items[0]); + $this->assertSame('SKU-1', $dto->items[0]->sku); + $this->assertSame(1, $dto->items[1]->quantity); + + // The DTO is only hydrated once for the same expected class. + $this->assertSame($dto, $message->getDto(OrderDto::class)); + + // The raw data is still accessible as an array. + $this->assertSame($parsedBody['data'], $message->getArgument()); + $this->assertSame(7, $message->getArgument('id')); + } + + /** + * Test that DTOs using a `createFromArray()` factory are supported. + * + * @return void + */ + public function testGetDtoWithCreateFromArray() + { + $parsedBody = [ + 'class' => [WelcomeMailer::class, 'welcome'], + 'data' => [ + 'id' => 3, + 'username' => 'markstory', + ], + 'dtoClass' => UserDto::class, + ]; + $connectionFactory = new NullConnectionFactory(); + $context = $connectionFactory->createContext(); + $originalMessage = new NullMessage((string)json_encode($parsedBody)); + $message = new Message($originalMessage, $context); + + $dto = $message->getDto(UserDto::class); + $this->assertInstanceOf(UserDto::class, $dto); + $this->assertSame(3, $dto->id); + $this->assertSame('markstory', $dto->username); + } + + /** + * Test that hydration uses the caller-supplied class even when the body has + * no `dtoClass` metadata (legacy array messages / gradual adoption). + * + * @return void + */ + public function testGetDtoWithoutDtoClassMetadata() + { + $parsedBody = [ + 'class' => [WelcomeMailer::class, 'welcome'], + 'data' => [ + 'id' => 7, + 'customer' => 'Acme Corp', + 'items' => [ + ['sku' => 'SKU-1', 'quantity' => 2], + ], + ], + ]; + $connectionFactory = new NullConnectionFactory(); + $context = $connectionFactory->createContext(); + $originalMessage = new NullMessage((string)json_encode($parsedBody)); + $message = new Message($originalMessage, $context); + + $this->assertNull($message->getDtoClass()); + + $dto = $message->getDto(OrderDto::class); + $this->assertInstanceOf(OrderDto::class, $dto); + $this->assertSame(7, $dto->id); + $this->assertSame('Acme Corp', $dto->customer); + } + + /** + * Test that a tampered / unresolvable `dtoClass` on the body is ignored — + * only the class passed to `getDto()` is instantiated. + * + * @return void + */ + public function testGetDtoIgnoresUntrustedBodyDtoClass() + { + $parsedBody = [ + 'class' => [WelcomeMailer::class, 'welcome'], + 'data' => [ + 'id' => 7, + 'customer' => 'Acme Corp', + 'items' => [], + ], + 'dtoClass' => 'TestApp\Dto\DoesNotExist', + ]; + $connectionFactory = new NullConnectionFactory(); + $context = $connectionFactory->createContext(); + $originalMessage = new NullMessage((string)json_encode($parsedBody)); + $message = new Message($originalMessage, $context); + + $this->assertNull($message->getDtoClass()); + + $dto = $message->getDto(OrderDto::class); + $this->assertInstanceOf(OrderDto::class, $dto); + $this->assertSame(7, $dto->id); + } + + /** + * Test that requesting a class that cannot be autoloaded throws. + * + * @return void + */ + public function testGetDtoThrowsForMissingExpectedClass() + { + $parsedBody = [ + 'class' => [WelcomeMailer::class, 'welcome'], + 'data' => ['id' => 7], + ]; + $connectionFactory = new NullConnectionFactory(); + $context = $connectionFactory->createContext(); + $originalMessage = new NullMessage((string)json_encode($parsedBody)); + $message = new Message($originalMessage, $context); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('DTO class `TestApp\Dto\DoesNotExist` does not exist.'); + $message->getDto('TestApp\Dto\DoesNotExist'); + } + /** * Test that invalid classes cannot be made into callables. * diff --git a/tests/TestCase/Queue/ProcessorTest.php b/tests/TestCase/Queue/ProcessorTest.php index d0161de..853aa52 100644 --- a/tests/TestCase/Queue/ProcessorTest.php +++ b/tests/TestCase/Queue/ProcessorTest.php @@ -28,6 +28,8 @@ use Enqueue\Null\NullMessage; use Interop\Queue\Processor as InteropProcessor; use PHPUnit\Framework\Attributes\DataProvider; +use TestApp\Dto\OrderDto; +use TestApp\Job\DtoJob; use TestApp\TestProcessor; use TestApp\WelcomeMailer; use Traversable; @@ -244,6 +246,37 @@ public function testProcessJobObject() $this->assertSame(InteropProcessor::ACK, $result); } + /** + * Test that a job receives its data hydrated back into a DTO. + * + * @return void + */ + public function testProcessMessageWithDto() + { + $messageBody = [ + 'class' => [DtoJob::class, 'execute'], + 'data' => [ + 'id' => 7, + 'customer' => 'Acme Corp', + 'items' => [], + ], + 'dtoClass' => OrderDto::class, + ]; + $connectionFactory = new NullConnectionFactory(); + $context = $connectionFactory->createContext(); + $queueMessage = new NullMessage((string)json_encode($messageBody)); + $processor = new Processor(); + + $result = $processor->process($queueMessage, $context); + + $this->assertSame(InteropProcessor::ACK, $result); + $this->assertInstanceOf(OrderDto::class, DtoJob::$lastDto); + $this->assertSame(7, DtoJob::$lastDto->id); + $this->assertSame('Acme Corp', DtoJob::$lastDto->customer); + + DtoJob::$lastDto = null; + } + /** * Test processMessage method. * diff --git a/tests/TestCase/QueueManagerTest.php b/tests/TestCase/QueueManagerTest.php index c584751..8999eb3 100644 --- a/tests/TestCase/QueueManagerTest.php +++ b/tests/TestCase/QueueManagerTest.php @@ -24,6 +24,8 @@ use Cake\TestSuite\TestCase; use Enqueue\SimpleClient\SimpleClient; use LogicException; +use TestApp\Dto\OrderDto; +use TestApp\Dto\OrderItemDto; use TestApp\Job\LogToDebugJob; use TestApp\Job\UniqueJob; use TypeError; @@ -95,6 +97,30 @@ public function testGetUniqueId() $this->assertEquals($first, $second, 'nested arrays are sorted too'); } + /** + * Test that the dtoClass argument is factored into the unique hash so two + * different DTO types that happen to serialize identically don't collide. + * + * @return void + */ + public function testGetUniqueIdWithDtoClass() + { + $data = ['id' => 7, 'customer' => 'Acme Corp']; + + $withoutDto = QueueManager::getUniqueId('Example', 'hello', $data); + $withNullDto = QueueManager::getUniqueId('Example', 'hello', $data); + $this->assertSame($withoutDto, $withNullDto, 'omitting dtoClass matches an explicit null'); + + $withOrderDto = QueueManager::getUniqueId('Example', 'hello', $data, 'App\Dto\OrderDto'); + $this->assertNotEquals($withoutDto, $withOrderDto, 'a dtoClass changes the hash'); + + $withOtherDto = QueueManager::getUniqueId('Example', 'hello', $data, 'App\Dto\OtherDto'); + $this->assertNotEquals($withOrderDto, $withOtherDto, 'different dtoClasses with identical data are distinct'); + + $withOrderDtoAgain = QueueManager::getUniqueId('Example', 'hello', $data, 'App\Dto\OrderDto'); + $this->assertSame($withOrderDto, $withOrderDtoAgain, 'same dtoClass and data are the same'); + } + public function testSetConfig() { QueueManager::setConfig('test', [ @@ -223,6 +249,61 @@ public function testMessageIsPushedToQueuePassedAsOption() $this->assertStringContainsString('non-default-queue-name', file_get_contents($fsQueueFile)); } + public function testPushWithDtoObject() + { + QueueManager::setConfig('test', [ + 'url' => $this->getFsQueueUrl(), + 'queue' => 'test', + ]); + + $dto = new OrderDto(7, 'Acme Corp', [ + new OrderItemDto('SKU-1', 2), + ]); + QueueManager::push(LogToDebugJob::class, $dto, ['config' => 'test']); + + $fsQueueFile = $this->getFsQueueUrl() . DS . 'enqueue.app.test'; + $this->assertFileExists($fsQueueFile); + $contents = file_get_contents($fsQueueFile); + $this->assertStringContainsString('dtoClass', $contents); + $this->assertStringContainsString('OrderDto', $contents); + $this->assertStringContainsString('Acme Corp', $contents); + $this->assertStringContainsString('SKU-1', $contents); + } + + public function testPushWithDtoClassOption() + { + QueueManager::setConfig('test', [ + 'url' => $this->getFsQueueUrl(), + 'queue' => 'test', + ]); + + QueueManager::push(LogToDebugJob::class, [ + 'id' => 7, + 'customer' => 'Acme Corp', + ], ['config' => 'test', 'dtoClass' => OrderDto::class]); + + $fsQueueFile = $this->getFsQueueUrl() . DS . 'enqueue.app.test'; + $this->assertFileExists($fsQueueFile); + $contents = file_get_contents($fsQueueFile); + $this->assertStringContainsString('dtoClass', $contents); + $this->assertStringContainsString('OrderDto', $contents); + } + + public function testPushWithoutDtoDoesNotAddDtoClass() + { + QueueManager::setConfig('test', [ + 'url' => $this->getFsQueueUrl(), + 'queue' => 'test', + ]); + + QueueManager::push(LogToDebugJob::class, ['id' => 7], ['config' => 'test']); + + $fsQueueFile = $this->getFsQueueUrl() . DS . 'enqueue.app.test'; + $this->assertFileExists($fsQueueFile); + $contents = file_get_contents($fsQueueFile); + $this->assertStringNotContainsString('dtoClass', $contents); + } + public function testUniqueMessageIsQueuedOnlyOnce() { QueueManager::setConfig('test', [ @@ -241,6 +322,62 @@ public function testUniqueMessageIsQueuedOnlyOnce() $this->assertSame(1, substr_count(file_get_contents($fsQueueFile), 'UniqueJob')); } + /** + * Test that pushing the same DTO twice for a unique job only queues it once. + * + * @return void + */ + public function testUniqueMessageWithDtoObjectIsQueuedOnlyOnce() + { + QueueManager::setConfig('test', [ + 'url' => $this->getFsQueueUrl(), + 'queue' => 'test', + 'uniqueCache' => [ + 'engine' => 'File', + ], + ]); + + $first = new OrderDto(7, 'Acme Corp', [ + new OrderItemDto('SKU-1', 2), + ]); + $second = new OrderDto(7, 'Acme Corp', [ + new OrderItemDto('SKU-1', 2), + ]); + + QueueManager::push(UniqueJob::class, $first, ['config' => 'test']); + QueueManager::push(UniqueJob::class, $second, ['config' => 'test']); + + $fsQueueFile = $this->getFsQueueUrl() . DS . 'enqueue.app.test'; + $this->assertFileExists($fsQueueFile); + $this->assertSame(1, substr_count(file_get_contents($fsQueueFile), 'UniqueJob')); + } + + /** + * Test that pushing DTOs with different field values for a unique job queues both. + * + * @return void + */ + public function testUniqueMessageWithDifferentDtoObjectsAreBothQueued() + { + QueueManager::setConfig('test', [ + 'url' => $this->getFsQueueUrl(), + 'queue' => 'test', + 'uniqueCache' => [ + 'engine' => 'File', + ], + ]); + + $first = new OrderDto(7, 'Acme Corp', []); + $second = new OrderDto(8, 'Other Corp', []); + + QueueManager::push(UniqueJob::class, $first, ['config' => 'test']); + QueueManager::push(UniqueJob::class, $second, ['config' => 'test']); + + $fsQueueFile = $this->getFsQueueUrl() . DS . 'enqueue.app.test'; + $this->assertFileExists($fsQueueFile); + $this->assertSame(2, substr_count(file_get_contents($fsQueueFile), 'UniqueJob')); + } + public function testDroppedJobIsLoggedForUniqueJob() { Log::setConfig('debug', [ diff --git a/tests/TestCase/TestSuite/QueueTestSuiteTest.php b/tests/TestCase/TestSuite/QueueTestSuiteTest.php index 0747165..c709f28 100644 --- a/tests/TestCase/TestSuite/QueueTestSuiteTest.php +++ b/tests/TestCase/TestSuite/QueueTestSuiteTest.php @@ -665,7 +665,7 @@ public function testCreateConsumerWithOtherDestination(): void public function testCreateConsumerWithTopicOnlyDestination(): void { $context = new TestContext(); - $topic = $this->createMock(Topic::class); + $topic = $this->createStub(Topic::class); $topic->method('getTopicName')->willReturn('test-topic'); $consumer = $context->createConsumer($topic); diff --git a/tests/comparisons/JobTask.php b/tests/comparisons/JobTask.php index 822a677..f1e89ae 100644 --- a/tests/comparisons/JobTask.php +++ b/tests/comparisons/JobTask.php @@ -16,7 +16,6 @@ class UploadJob implements JobInterface * Executes logic for UploadJob * * @param \Cake\Queue\Job\Message $message job message - * @return string|null */ public function execute(Message $message): ?string { diff --git a/tests/comparisons/JobTaskWithMaxAttempts.php b/tests/comparisons/JobTaskWithMaxAttempts.php index d8f4c91..ce5f606 100644 --- a/tests/comparisons/JobTaskWithMaxAttempts.php +++ b/tests/comparisons/JobTaskWithMaxAttempts.php @@ -23,7 +23,6 @@ class UploadJob implements JobInterface * Executes logic for UploadJob * * @param \Cake\Queue\Job\Message $message job message - * @return string|null */ public function execute(Message $message): ?string { diff --git a/tests/comparisons/JobTaskWithUnique.php b/tests/comparisons/JobTaskWithUnique.php index f0faf36..2b9aa8a 100644 --- a/tests/comparisons/JobTaskWithUnique.php +++ b/tests/comparisons/JobTaskWithUnique.php @@ -23,7 +23,6 @@ class UploadJob implements JobInterface * Executes logic for UploadJob * * @param \Cake\Queue\Job\Message $message job message - * @return string|null */ public function execute(Message $message): ?string { diff --git a/tests/test_app/src/Dto/InvalidDto.php b/tests/test_app/src/Dto/InvalidDto.php new file mode 100644 index 0000000..bdf53b2 --- /dev/null +++ b/tests/test_app/src/Dto/InvalidDto.php @@ -0,0 +1,12 @@ + $this->id, + 'label' => strtoupper($this->name), + ]; + } +} diff --git a/tests/test_app/src/Dto/OrderDto.php b/tests/test_app/src/Dto/OrderDto.php new file mode 100644 index 0000000..928a839 --- /dev/null +++ b/tests/test_app/src/Dto/OrderDto.php @@ -0,0 +1,20 @@ + $items + */ + public function __construct( + public int $id, + public string $customer, + #[CollectionOf(OrderItemDto::class)] + public array $items = [], + ) { + } +} diff --git a/tests/test_app/src/Dto/OrderItemDto.php b/tests/test_app/src/Dto/OrderItemDto.php new file mode 100644 index 0000000..a4e3ab8 --- /dev/null +++ b/tests/test_app/src/Dto/OrderItemDto.php @@ -0,0 +1,13 @@ +value; + } +} diff --git a/tests/test_app/src/Dto/UserDto.php b/tests/test_app/src/Dto/UserDto.php new file mode 100644 index 0000000..6028243 --- /dev/null +++ b/tests/test_app/src/Dto/UserDto.php @@ -0,0 +1,21 @@ + $data + */ + public static function createFromArray(array $data, bool $nested = false): static + { + return new static($data['id'], $data['username']); + } + + public function __construct( + public int $id, + public string $username, + ) { + } +} diff --git a/tests/test_app/src/Job/DtoJob.php b/tests/test_app/src/Job/DtoJob.php new file mode 100644 index 0000000..7fabbcb --- /dev/null +++ b/tests/test_app/src/Job/DtoJob.php @@ -0,0 +1,21 @@ +getDto(OrderDto::class); + + return Processor::ACK; + } +}