diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e6e5e8..c4a99c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,49 @@ All notable changes to this project will be documented in this file. This projec ## Unreleased +### Added + +- New command bus features: + - Can now use a PSR container for the command bus to resolve both handlers and middleware. Inject the service + container via the first constructor argument. + - Commands can now be mapped to handlers on a command bus class via the `WithCommand` attribute. + - Middleware can now be added to a command bus via the `Through` attribute. +- New query bus features: + - Can now use a PSR container for the query bus to resolve both handlers and middleware. Inject the service + container via the first constructor argument. + - Queries can now be mapped to handlers on a query bus class via the `WithQuery` attribute. + - Middleware can now be added to a query bus via the `Through` attribute. +- New inbound event bus features: + - Can now use a PSR container for the inbound event bus to resolve both handlers and middleware. Inject the service + container via the first constructor argument. + - Integration events can now be mapped to handlers on an inbound event bus class via the `WithEvent` attribute. + - The default handler can be set on the inbound event bus via the `WithDefault` attribute. + - Middleware can now be added to an inbound event bus via the `Through` attribute. +- New outbound event bus features, when using the component publisher: + - Can now use a PSR container for the outbound event bus to resolve both publishers and middleware. Inject the + service container via the constructor. + - Integration events can now be mapped to publishers on a publisher handler container class via the `Publishes` + attribute. + - The default publisher can be set on the outbound event publisher via the `DefaultPublisher` attribute. + - Middleware can now be added to an outbound event publisher via the `Through` attribute. +- New queue features, when using the component queue: + - Can now use a PSR container for the queue to resolve both enqueuers and middleware. Inject the service container + via the constructor. + - Commands can now be mapped to enqueuers on a publisher handler container class via the `Queues` attribute. + - The default enqueuer can be set on the outbound event publisher via the `DefaultEnqueuer` attribute. + - Middleware can now be added to the queue via the `Through` attribute. +- In the Application layer, the `QueryHandlerContainer`, `CommandHandlerContainer` and `EventHandlerContainer` classes + can now fallback to resolving handlers from a PSR service container. Inject the service container via their + constructors. +- In the Infrastructure layer, the `PublisherHandlerContainer` and `EnqueuerContainer` can now fallback to resolving + handlers/enqueuers from a PSR service container. Inject the service container via the constructor. +- The outbound event bus `ClosurePublisher` and the `ClosureQueue` classes now both accept a PSR container for their + middleware. Additionally, middleware can be set on instances of closure publishers via the `Through` attribute. +- The pipeline `PipeContainer` class can now fallback to resolving pipes from a PSR service container. Inject the + service container via the pipe container's only constructor argument. +- The `FakeUnitOfWork` class now has integer properties for the number of attempts, commits and rollbacks. +- New `FakeContainer` class for faking a PSR container in tests. + ## [5.0.0] - 2025-12-09 ### Added diff --git a/composer.json b/composer.json index caf5551..87b1cfa 100644 --- a/composer.json +++ b/composer.json @@ -23,9 +23,11 @@ "require": { "php": "^8.2", "ext-json": "*", + "psr/container": "^2.0", "psr/log": "^2.0 || ^3.0", "ramsey/uuid": "^4.7", - "symfony/polyfill-php84": "^1.33" + "symfony/polyfill-php84": "^1.33", + "symfony/polyfill-php85": "^1.33" }, "require-dev": { "deptrac/deptrac": "^4.4", diff --git a/deptrac.yaml b/deptrac.yaml index 2287983..7ce16fe 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -28,6 +28,10 @@ deptrac: value: CloudCreativity\\Modules\\Contracts\\Infrastructure\\* - type: classLike value: CloudCreativity\\Modules\\Infrastructure\\* + - name: PSR Container + collectors: + - type: classLike + value: Psr\\Container\\* - name: PSR Log collectors: - type: classLike @@ -39,17 +43,20 @@ deptrac: ruleset: Toolkit: - Attributes + - PSR Container Domain: - Toolkit - Attributes Application: - Toolkit - Domain + - PSR Container - PSR Log - Attributes Infrastructure: - Toolkit - Domain - Application + - PSR Container - PSR Log - Attributes diff --git a/phpunit.xml b/phpunit.xml index 2968136..c830534 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -19,6 +19,9 @@ ./tests/Unit/ + + ./tests/Integration/ + diff --git a/src/Application/Bus/CommandDispatcher.php b/src/Application/Bus/CommandDispatcher.php index 2a33820..3d975b9 100644 --- a/src/Application/Bus/CommandDispatcher.php +++ b/src/Application/Bus/CommandDispatcher.php @@ -12,25 +12,42 @@ namespace CloudCreativity\Modules\Application\Bus; -use CloudCreativity\Modules\Contracts\Application\Bus\CommandHandlerContainer; +use CloudCreativity\Modules\Contracts\Application\Bus\CommandHandlerContainer as ICommandHandlerContainer; use CloudCreativity\Modules\Contracts\Application\Ports\Driving\CommandDispatcher as ICommandDispatcher; use CloudCreativity\Modules\Contracts\Toolkit\Messages\Command; -use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer; +use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer as IPipeContainer; use CloudCreativity\Modules\Contracts\Toolkit\Result\Result; use CloudCreativity\Modules\Toolkit\Pipeline\MiddlewareProcessor; +use CloudCreativity\Modules\Toolkit\Pipeline\PipeContainer; use CloudCreativity\Modules\Toolkit\Pipeline\PipelineBuilder; +use CloudCreativity\Modules\Toolkit\Pipeline\Through; +use Psr\Container\ContainerInterface; +use ReflectionClass; class CommandDispatcher implements ICommandDispatcher { + private readonly ICommandHandlerContainer $handlers; + + private readonly ?IPipeContainer $middleware; + /** * @var array */ private array $pipes = []; public function __construct( - private readonly CommandHandlerContainer $handlers, - private readonly ?PipeContainer $middleware = null, + ContainerInterface|ICommandHandlerContainer $handlers, + ?IPipeContainer $middleware = null, ) { + $this->handlers = $handlers instanceof ContainerInterface ? + new CommandHandlerContainer($handlers) : + $handlers; + + $this->middleware = $middleware === null && $handlers instanceof ContainerInterface + ? new PipeContainer($handlers) + : $middleware; + + $this->autowire(); } /** @@ -77,4 +94,21 @@ private function execute(Command $command): Result return $result; } + + private function autowire(): void + { + $reflection = new ReflectionClass($this); + + if ($this->handlers instanceof CommandHandlerContainer) { + foreach ($reflection->getAttributes(WithCommand::class) as $attribute) { + $instance = $attribute->newInstance(); + $this->handlers->bind($instance->command, $instance->handler); + } + } + + foreach ($reflection->getAttributes(Through::class) as $attribute) { + $instance = $attribute->newInstance(); + $this->pipes = $instance->pipes; + } + } } diff --git a/src/Application/Bus/CommandHandler.php b/src/Application/Bus/CommandHandler.php index 5ce85a6..5853e6a 100644 --- a/src/Application/Bus/CommandHandler.php +++ b/src/Application/Bus/CommandHandler.php @@ -12,13 +12,15 @@ namespace CloudCreativity\Modules\Application\Bus; +use CloudCreativity\Modules\Application\Messages\HandlesMessages; use CloudCreativity\Modules\Contracts\Application\Bus\CommandHandler as ICommandHandler; -use CloudCreativity\Modules\Contracts\Application\Messages\DispatchThroughMiddleware; use CloudCreativity\Modules\Contracts\Toolkit\Messages\Command; use CloudCreativity\Modules\Contracts\Toolkit\Result\Result; final readonly class CommandHandler implements ICommandHandler { + use HandlesMessages; + public function __construct(private object $handler) { } @@ -37,13 +39,4 @@ public function __invoke(Command $command): Result return $result; } - - public function middleware(): array - { - if ($this->handler instanceof DispatchThroughMiddleware) { - return $this->handler->middleware(); - } - - return []; - } } diff --git a/src/Application/Bus/CommandHandlerContainer.php b/src/Application/Bus/CommandHandlerContainer.php index 5c74c2b..ca7b505 100644 --- a/src/Application/Bus/CommandHandlerContainer.php +++ b/src/Application/Bus/CommandHandlerContainer.php @@ -16,33 +16,48 @@ use CloudCreativity\Modules\Application\ApplicationException; use CloudCreativity\Modules\Contracts\Application\Bus\CommandHandlerContainer as ICommandHandlerContainer; use CloudCreativity\Modules\Contracts\Toolkit\Messages\Command; +use Psr\Container\ContainerInterface; final class CommandHandlerContainer implements ICommandHandlerContainer { /** - * @var array, Closure> + * @var array, class-string|Closure> */ private array $bindings = []; + public function __construct(private readonly ?ContainerInterface $container = null) + { + } + /** * Bind a command handler into the container. * * @param class-string $commandClass - * @param Closure(): object $binding + * @param class-string|(Closure(): object) $binding */ - public function bind(string $commandClass, Closure $binding): void + public function bind(string $commandClass, Closure|string $binding): void { + if (is_string($binding) && $this->container === null) { + throw new ApplicationException('Cannot use a string command handler binding without a PSR container.'); + } + $this->bindings[$commandClass] = $binding; } public function get(string $commandClass): CommandHandler { - $factory = $this->bindings[$commandClass] ?? null; + $binding = $this->bindings[$commandClass] ?? null; + + if ($binding instanceof Closure) { + $instance = $binding(); + assert(is_object($instance), "Command handler binding for {$commandClass} must return an object."); + return new CommandHandler($instance); + } - if ($factory) { - $innerHandler = $factory(); - assert(is_object($innerHandler), "Command handler binding for {$commandClass} must return an object."); - return new CommandHandler($innerHandler); + if (is_string($binding)) { + $instance = $this->container?->get($binding); + assert(is_object($instance), "PSR container command handler binding {$binding} is not an object."); + return new CommandHandler($instance); } throw new ApplicationException('No command handler bound for command class: ' . $commandClass); diff --git a/src/Application/Bus/QueryDispatcher.php b/src/Application/Bus/QueryDispatcher.php index a19ec4d..dbc9e10 100644 --- a/src/Application/Bus/QueryDispatcher.php +++ b/src/Application/Bus/QueryDispatcher.php @@ -12,25 +12,42 @@ namespace CloudCreativity\Modules\Application\Bus; -use CloudCreativity\Modules\Contracts\Application\Bus\QueryHandlerContainer; +use CloudCreativity\Modules\Contracts\Application\Bus\QueryHandlerContainer as IQueryHandlerContainer; use CloudCreativity\Modules\Contracts\Application\Ports\Driving\QueryDispatcher as IQueryDispatcher; use CloudCreativity\Modules\Contracts\Toolkit\Messages\Query; -use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer; +use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer as IPipeContainer; use CloudCreativity\Modules\Contracts\Toolkit\Result\Result; use CloudCreativity\Modules\Toolkit\Pipeline\MiddlewareProcessor; +use CloudCreativity\Modules\Toolkit\Pipeline\PipeContainer; use CloudCreativity\Modules\Toolkit\Pipeline\PipelineBuilder; +use CloudCreativity\Modules\Toolkit\Pipeline\Through; +use Psr\Container\ContainerInterface; +use ReflectionClass; class QueryDispatcher implements IQueryDispatcher { + private readonly IQueryHandlerContainer $handlers; + + private readonly ?IPipeContainer $middleware; + /** * @var array */ private array $pipes = []; public function __construct( - private readonly QueryHandlerContainer $handlers, - private readonly ?PipeContainer $middleware = null, + ContainerInterface|IQueryHandlerContainer $handlers, + ?IPipeContainer $middleware = null, ) { + $this->handlers = $handlers instanceof ContainerInterface ? + new QueryHandlerContainer($handlers) : + $handlers; + + $this->middleware = $middleware === null && $handlers instanceof ContainerInterface + ? new PipeContainer($handlers) + : $middleware; + + $this->autowire(); } /** @@ -77,4 +94,22 @@ private function execute(Query $query): Result return $result; } + + + private function autowire(): void + { + $reflection = new ReflectionClass($this); + + if ($this->handlers instanceof QueryHandlerContainer) { + foreach ($reflection->getAttributes(WithQuery::class) as $attribute) { + $instance = $attribute->newInstance(); + $this->handlers->bind($instance->query, $instance->handler); + } + } + + foreach ($reflection->getAttributes(Through::class) as $attribute) { + $instance = $attribute->newInstance(); + $this->pipes = $instance->pipes; + } + } } diff --git a/src/Application/Bus/QueryHandler.php b/src/Application/Bus/QueryHandler.php index 8b4470c..9d0dd50 100644 --- a/src/Application/Bus/QueryHandler.php +++ b/src/Application/Bus/QueryHandler.php @@ -12,13 +12,15 @@ namespace CloudCreativity\Modules\Application\Bus; +use CloudCreativity\Modules\Application\Messages\HandlesMessages; use CloudCreativity\Modules\Contracts\Application\Bus\QueryHandler as IQueryHandler; -use CloudCreativity\Modules\Contracts\Application\Messages\DispatchThroughMiddleware; use CloudCreativity\Modules\Contracts\Toolkit\Messages\Query; use CloudCreativity\Modules\Contracts\Toolkit\Result\Result; final readonly class QueryHandler implements IQueryHandler { + use HandlesMessages; + public function __construct(private object $handler) { } @@ -37,13 +39,4 @@ public function __invoke(Query $query): Result return $result; } - - public function middleware(): array - { - if ($this->handler instanceof DispatchThroughMiddleware) { - return $this->handler->middleware(); - } - - return []; - } } diff --git a/src/Application/Bus/QueryHandlerContainer.php b/src/Application/Bus/QueryHandlerContainer.php index 4352fe5..892df79 100644 --- a/src/Application/Bus/QueryHandlerContainer.php +++ b/src/Application/Bus/QueryHandlerContainer.php @@ -16,33 +16,48 @@ use CloudCreativity\Modules\Application\ApplicationException; use CloudCreativity\Modules\Contracts\Application\Bus\QueryHandlerContainer as IQueryHandlerContainer; use CloudCreativity\Modules\Contracts\Toolkit\Messages\Query; +use Psr\Container\ContainerInterface; final class QueryHandlerContainer implements IQueryHandlerContainer { /** - * @var array,Closure> + * @var array, class-string|Closure> */ private array $bindings = []; + public function __construct(private readonly ?ContainerInterface $container = null) + { + } + /** * Bind a query handler into the container. * * @param class-string $queryClass - * @param Closure(): object $binding + * @param class-string|(Closure(): object) $binding */ - public function bind(string $queryClass, Closure $binding): void + public function bind(string $queryClass, Closure|string $binding): void { + if (is_string($binding) && !$this->container) { + throw new ApplicationException('Cannot use a string query handler binding without a PSR container.'); + } + $this->bindings[$queryClass] = $binding; } public function get(string $queryClass): QueryHandler { - $factory = $this->bindings[$queryClass] ?? null; + $binding = $this->bindings[$queryClass] ?? null; + + if ($binding instanceof Closure) { + $instance = $binding(); + assert(is_object($instance), "Query handler binding for {$queryClass} must return an object."); + return new QueryHandler($instance); + } - if ($factory) { - $innerHandler = $factory(); - assert(is_object($innerHandler), "Query handler binding for {$queryClass} must return an object."); - return new QueryHandler($innerHandler); + if (is_string($binding)) { + $instance = $this->container?->get($binding); + assert(is_object($instance), "PSR container query handler binding {$binding} is not an object."); + return new QueryHandler($instance); } throw new ApplicationException('No query handler bound for query class: ' . $queryClass); diff --git a/src/Application/Bus/WithCommand.php b/src/Application/Bus/WithCommand.php new file mode 100644 index 0000000..d689ce4 --- /dev/null +++ b/src/Application/Bus/WithCommand.php @@ -0,0 +1,28 @@ + $command + * @param class-string $handler + */ + public function __construct(public string $command, public string $handler) + { + } +} diff --git a/src/Application/Bus/WithQuery.php b/src/Application/Bus/WithQuery.php new file mode 100644 index 0000000..4dafba1 --- /dev/null +++ b/src/Application/Bus/WithQuery.php @@ -0,0 +1,28 @@ + $query + * @param class-string $handler + */ + public function __construct(public string $query, public string $handler) + { + } +} diff --git a/src/Application/InboundEventBus/EventHandler.php b/src/Application/InboundEventBus/EventHandler.php index 1f86055..8e0def6 100644 --- a/src/Application/InboundEventBus/EventHandler.php +++ b/src/Application/InboundEventBus/EventHandler.php @@ -12,12 +12,14 @@ namespace CloudCreativity\Modules\Application\InboundEventBus; +use CloudCreativity\Modules\Application\Messages\HandlesMessages; use CloudCreativity\Modules\Contracts\Application\InboundEventBus\EventHandler as IEventHandler; -use CloudCreativity\Modules\Contracts\Application\Messages\DispatchThroughMiddleware; use CloudCreativity\Modules\Contracts\Toolkit\Messages\IntegrationEvent; final readonly class EventHandler implements IEventHandler { + use HandlesMessages; + public function __construct(private object $handler) { } @@ -32,13 +34,4 @@ public function __invoke(IntegrationEvent $event): void $this->handler->handle($event); } - - public function middleware(): array - { - if ($this->handler instanceof DispatchThroughMiddleware) { - return $this->handler->middleware(); - } - - return []; - } } diff --git a/src/Application/InboundEventBus/EventHandlerContainer.php b/src/Application/InboundEventBus/EventHandlerContainer.php index 5a9cef1..e5007bb 100644 --- a/src/Application/InboundEventBus/EventHandlerContainer.php +++ b/src/Application/InboundEventBus/EventHandlerContainer.php @@ -16,40 +16,68 @@ use CloudCreativity\Modules\Application\ApplicationException; use CloudCreativity\Modules\Contracts\Application\InboundEventBus\EventHandlerContainer as IEventHandlerContainer; use CloudCreativity\Modules\Contracts\Toolkit\Messages\IntegrationEvent; +use Psr\Container\ContainerInterface; final class EventHandlerContainer implements IEventHandlerContainer { /** - * @var array, Closure> + * @var array, Closure|non-empty-string> */ private array $bindings = []; /** - * @param ?Closure(): object $default + * @param (Closure(): object)|non-empty-string|null $default */ - public function __construct(private readonly ?Closure $default = null) - { + public function __construct( + private Closure|string|null $default = null, + private readonly ?ContainerInterface $container = null, + ) { } /** * Bind a handler factory into the container. * * @param class-string $eventName - * @param Closure(): object $binding + * @param (Closure(): object)|non-empty-string $binding */ - public function bind(string $eventName, Closure $binding): void + public function bind(string $eventName, Closure|string $binding): void { + if (is_string($binding) && $this->container === null) { + throw new ApplicationException('Cannot use a string event handler binding without a PSR container.'); + } + $this->bindings[$eventName] = $binding; } + /** + * Bind a default handler factory into the container. + * + * @param (Closure(): object)|non-empty-string $binding + */ + public function withDefault(Closure|string $binding): void + { + if ($this->default === null) { + $this->default = $binding; + return; + } + + throw new ApplicationException('Default event handler binding is already set.'); + } + public function get(string $eventName): EventHandler { - $factory = $this->bindings[$eventName] ?? $this->default; + $binding = $this->bindings[$eventName] ?? $this->default; + + if ($binding instanceof Closure) { + $instance = $binding(); + assert(is_object($instance), "Handler binding for integration event {$eventName} must return an object."); + return new EventHandler($instance); + } - if ($factory) { - $handler = $factory(); - assert(is_object($handler), "Handler binding for integration event {$eventName} must return an object."); - return new EventHandler($handler); + if (is_string($binding)) { + $instance = $this->container?->get($binding); + assert(is_object($instance), "PSR container event handler binding {$binding} is not an object."); + return new EventHandler($instance); } throw new ApplicationException('No handler bound for integration event: ' . $eventName); diff --git a/src/Application/InboundEventBus/InboundEventDispatcher.php b/src/Application/InboundEventBus/InboundEventDispatcher.php index 691905c..60f4da5 100644 --- a/src/Application/InboundEventBus/InboundEventDispatcher.php +++ b/src/Application/InboundEventBus/InboundEventDispatcher.php @@ -12,24 +12,40 @@ namespace CloudCreativity\Modules\Application\InboundEventBus; -use CloudCreativity\Modules\Contracts\Application\InboundEventBus\EventHandlerContainer; +use CloudCreativity\Modules\Contracts\Application\InboundEventBus\EventHandlerContainer as IEventHandlerContainer; use CloudCreativity\Modules\Contracts\Application\Ports\Driving\InboundEventDispatcher as IInboundEventDispatcher; use CloudCreativity\Modules\Contracts\Toolkit\Messages\IntegrationEvent; -use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer; +use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer as IPipeContainer; use CloudCreativity\Modules\Toolkit\Pipeline\MiddlewareProcessor; +use CloudCreativity\Modules\Toolkit\Pipeline\PipeContainer; use CloudCreativity\Modules\Toolkit\Pipeline\PipelineBuilder; +use CloudCreativity\Modules\Toolkit\Pipeline\Through; +use Psr\Container\ContainerInterface; class InboundEventDispatcher implements IInboundEventDispatcher { + private readonly IEventHandlerContainer $handlers; + + private readonly ?IPipeContainer $middleware; + /** * @var array */ private array $pipes = []; public function __construct( - private readonly EventHandlerContainer $handlers, - private readonly ?PipeContainer $middleware = null, + ContainerInterface|IEventHandlerContainer $handlers, + ?IPipeContainer $middleware = null, ) { + $this->handlers = $handlers instanceof ContainerInterface ? + new EventHandlerContainer(container: $handlers) : + $handlers; + + $this->middleware = $middleware === null && $handlers instanceof ContainerInterface ? + new PipeContainer(container: $handlers) : + $middleware; + + $this->autowire(); } /** @@ -65,4 +81,26 @@ private function execute(IntegrationEvent $event): void $pipeline->process($event); } + + private function autowire(): void + { + $reflection = new \ReflectionClass($this); + + if ($this->handlers instanceof EventHandlerContainer) { + foreach ($reflection->getAttributes(WithEvent::class) as $attribute) { + $instance = $attribute->newInstance(); + $this->handlers->bind($instance->event, $instance->handler); + } + + foreach ($reflection->getAttributes(WithDefault::class) as $attribute) { + $instance = $attribute->newInstance(); + $this->handlers->withDefault($instance->handler); + } + } + + foreach ($reflection->getAttributes(Through::class) as $attribute) { + $instance = $attribute->newInstance(); + $this->pipes = $instance->pipes; + } + } } diff --git a/src/Application/InboundEventBus/WithDefault.php b/src/Application/InboundEventBus/WithDefault.php new file mode 100644 index 0000000..2b91975 --- /dev/null +++ b/src/Application/InboundEventBus/WithDefault.php @@ -0,0 +1,26 @@ + $event + * @param class-string $handler + */ + public function __construct(public string $event, public string $handler) + { + } +} diff --git a/src/Application/Messages/HandlesMessages.php b/src/Application/Messages/HandlesMessages.php new file mode 100644 index 0000000..67b3b72 --- /dev/null +++ b/src/Application/Messages/HandlesMessages.php @@ -0,0 +1,36 @@ +handler instanceof DispatchThroughMiddleware) { + return $this->handler->middleware(); + } + + $reflection = new ReflectionClass($this->handler); + + foreach ($reflection->getAttributes(Through::class) as $attribute) { + $instance = $attribute->newInstance(); + return $instance->pipes; + } + + return []; + } +} diff --git a/src/Infrastructure/OutboundEventBus/ClosurePublisher.php b/src/Infrastructure/OutboundEventBus/ClosurePublisher.php index a541298..3b661cd 100644 --- a/src/Infrastructure/OutboundEventBus/ClosurePublisher.php +++ b/src/Infrastructure/OutboundEventBus/ClosurePublisher.php @@ -18,6 +18,9 @@ use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer; use CloudCreativity\Modules\Toolkit\Pipeline\MiddlewareProcessor; use CloudCreativity\Modules\Toolkit\Pipeline\PipelineBuilder; +use CloudCreativity\Modules\Toolkit\Pipeline\Through; +use Psr\Container\ContainerInterface; +use ReflectionClass; class ClosurePublisher implements OutboundEventPublisher { @@ -33,8 +36,9 @@ class ClosurePublisher implements OutboundEventPublisher public function __construct( private readonly Closure $fn, - private readonly ?PipeContainer $middleware = null, + private readonly ContainerInterface|PipeContainer|null $middleware = null, ) { + $this->autowire(); } /** @@ -67,4 +71,14 @@ public function publish(IntegrationEvent $event): void $pipeline->process($event); } + + private function autowire(): void + { + $reflection = new ReflectionClass($this); + + foreach ($reflection->getAttributes(Through::class) as $attribute) { + $instance = $attribute->newInstance(); + $this->pipes = $instance->pipes; + } + } } diff --git a/src/Infrastructure/OutboundEventBus/ComponentPublisher.php b/src/Infrastructure/OutboundEventBus/ComponentPublisher.php index d876d2e..4d9e715 100644 --- a/src/Infrastructure/OutboundEventBus/ComponentPublisher.php +++ b/src/Infrastructure/OutboundEventBus/ComponentPublisher.php @@ -13,23 +13,40 @@ namespace CloudCreativity\Modules\Infrastructure\OutboundEventBus; use CloudCreativity\Modules\Contracts\Application\Ports\Driven\OutboundEventPublisher; -use CloudCreativity\Modules\Contracts\Infrastructure\OutboundEventBus\PublisherHandlerContainer; +use CloudCreativity\Modules\Contracts\Infrastructure\OutboundEventBus\PublisherHandlerContainer as IPublisherHandlerContainer; use CloudCreativity\Modules\Contracts\Toolkit\Messages\IntegrationEvent; -use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer; +use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer as IPipeContainer; use CloudCreativity\Modules\Toolkit\Pipeline\MiddlewareProcessor; +use CloudCreativity\Modules\Toolkit\Pipeline\PipeContainer; use CloudCreativity\Modules\Toolkit\Pipeline\PipelineBuilder; +use CloudCreativity\Modules\Toolkit\Pipeline\Through; +use Psr\Container\ContainerInterface; +use ReflectionClass; class ComponentPublisher implements OutboundEventPublisher { + private readonly IPublisherHandlerContainer $handlers; + + private readonly ?IPipeContainer $middleware; + /** * @var array */ private array $pipes = []; public function __construct( - private readonly PublisherHandlerContainer $handlers, - private readonly ?PipeContainer $middleware = null, + ContainerInterface|IPublisherHandlerContainer $handlers, + ?IPipeContainer $middleware = null, ) { + $this->handlers = $handlers instanceof ContainerInterface ? + new PublisherHandlerContainer(container: $handlers) : + $handlers; + + $this->middleware = $middleware === null && $handlers instanceof ContainerInterface ? + new PipeContainer(container: $handlers) : + $middleware; + + $this->autowire(); } /** @@ -55,4 +72,24 @@ public function publish(IntegrationEvent $event): void $pipeline->process($event); } + + private function autowire(): void + { + $reflection = new ReflectionClass($this); + + if ($this->handlers instanceof PublisherHandlerContainer) { + foreach ($reflection->getAttributes(Publishes::class) as $attribute) { + $instance = $attribute->newInstance(); + $this->handlers->bind($instance->event, $instance->publisher); + } + + foreach ($reflection->getAttributes(DefaultPublisher::class) as $attribute) { + $this->handlers->withDefault($attribute->newInstance()->publisher); + } + } + + foreach ($reflection->getAttributes(Through::class) as $attribute) { + $this->pipes = $attribute->newInstance()->pipes; + } + } } diff --git a/src/Infrastructure/OutboundEventBus/DefaultPublisher.php b/src/Infrastructure/OutboundEventBus/DefaultPublisher.php new file mode 100644 index 0000000..d9ff172 --- /dev/null +++ b/src/Infrastructure/OutboundEventBus/DefaultPublisher.php @@ -0,0 +1,26 @@ +, Closure> + * @var array, Closure|string> */ private array $bindings = []; - public function __construct(private readonly ?Closure $default = null) - { + public function __construct( + private Closure|string|null $default = null, + private readonly ?ContainerInterface $container = null, + ) { + if (is_string($this->default) && $this->container === null) { + throw new InfrastructureException( + 'Cannot bind default event publisher handler as a string without a PSR container.', + ); + } } /** @@ -34,19 +42,46 @@ public function __construct(private readonly ?Closure $default = null) * * @param class-string $eventName */ - public function bind(string $eventName, Closure $binding): void + public function bind(string $eventName, Closure|string $binding): void { + if (is_string($binding) && $this->container === null) { + throw new InfrastructureException( + 'Cannot bind event publisher handler as a string without a PSR container.', + ); + } + $this->bindings[$eventName] = $binding; } + public function withDefault(Closure|string $binding): void + { + if ($this->default !== null) { + throw new InfrastructureException('Default event publisher handler is already set.'); + } + + if (is_string($binding) && $this->container === null) { + throw new InfrastructureException( + 'Cannot bind default event publisher handler as a string without a PSR container.', + ); + } + + $this->default = $binding; + } + public function get(string $eventName): PublisherHandler { - $factory = $this->bindings[$eventName] ?? $this->default; + $binding = $this->bindings[$eventName] ?? $this->default; + + if ($binding instanceof Closure) { + $instance = $binding(); + assert(is_object($instance), "Handler binding for integration event {$eventName} must return an object."); + return new PublisherHandler($instance); + } - if ($factory) { - $handler = $factory(); - assert(is_object($handler), "Handler binding for integration event {$eventName} must return an object."); - return new PublisherHandler($handler); + if (is_string($binding)) { + $instance = $this->container?->get($binding); + assert(is_object($instance), "PSR container event publisher handler binding {$binding} is not an object."); + return new PublisherHandler($instance); } throw new InfrastructureException('No handler bound for integration event: ' . $eventName); diff --git a/src/Infrastructure/OutboundEventBus/Publishes.php b/src/Infrastructure/OutboundEventBus/Publishes.php new file mode 100644 index 0000000..77d68c0 --- /dev/null +++ b/src/Infrastructure/OutboundEventBus/Publishes.php @@ -0,0 +1,28 @@ + $event + * @param class-string $publisher + */ + public function __construct(public string $event, public string $publisher) + { + } +} diff --git a/src/Infrastructure/Queue/ClosureQueue.php b/src/Infrastructure/Queue/ClosureQueue.php index 88a22aa..385dcc1 100644 --- a/src/Infrastructure/Queue/ClosureQueue.php +++ b/src/Infrastructure/Queue/ClosureQueue.php @@ -18,6 +18,9 @@ use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer; use CloudCreativity\Modules\Toolkit\Pipeline\MiddlewareProcessor; use CloudCreativity\Modules\Toolkit\Pipeline\PipelineBuilder; +use CloudCreativity\Modules\Toolkit\Pipeline\Through; +use Psr\Container\ContainerInterface; +use ReflectionClass; class ClosureQueue implements Queue { @@ -33,8 +36,9 @@ class ClosureQueue implements Queue public function __construct( private readonly Closure $fn, - private readonly ?PipeContainer $middleware = null, + private readonly ContainerInterface|PipeContainer|null $middleware = null, ) { + $this->autowire(); } /** @@ -67,4 +71,13 @@ public function push(Command $command): void $pipeline->process($command); } + + private function autowire(): void + { + $reflection = new ReflectionClass($this); + + foreach ($reflection->getAttributes(Through::class) as $attribute) { + $this->pipes = $attribute->newInstance()->pipes; + } + } } diff --git a/src/Infrastructure/Queue/ComponentQueue.php b/src/Infrastructure/Queue/ComponentQueue.php index 6bc4f18..dbc2938 100644 --- a/src/Infrastructure/Queue/ComponentQueue.php +++ b/src/Infrastructure/Queue/ComponentQueue.php @@ -13,23 +13,40 @@ namespace CloudCreativity\Modules\Infrastructure\Queue; use CloudCreativity\Modules\Contracts\Application\Ports\Driven\Queue; -use CloudCreativity\Modules\Contracts\Infrastructure\Queue\EnqueuerContainer; +use CloudCreativity\Modules\Contracts\Infrastructure\Queue\EnqueuerContainer as IEnqueuerContainer; use CloudCreativity\Modules\Contracts\Toolkit\Messages\Command; -use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer; +use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer as IPipeContainer; use CloudCreativity\Modules\Toolkit\Pipeline\MiddlewareProcessor; +use CloudCreativity\Modules\Toolkit\Pipeline\PipeContainer; use CloudCreativity\Modules\Toolkit\Pipeline\PipelineBuilder; +use CloudCreativity\Modules\Toolkit\Pipeline\Through; +use Psr\Container\ContainerInterface; +use ReflectionClass; class ComponentQueue implements Queue { + private readonly IEnqueuerContainer $enqueuers; + + private readonly ?IPipeContainer $middleware; + /** * @var list */ private array $pipes = []; public function __construct( - private readonly EnqueuerContainer $enqueuers, - private readonly ?PipeContainer $middleware = null, + ContainerInterface|IEnqueuerContainer $enqueuers, + ?IPipeContainer $middleware = null, ) { + $this->enqueuers = $enqueuers instanceof ContainerInterface ? + new EnqueuerContainer(container: $enqueuers) : + $enqueuers; + + $this->middleware = $middleware === null && $enqueuers instanceof ContainerInterface + ? new PipeContainer($enqueuers) + : $middleware; + + $this->autowire(); } /** @@ -53,4 +70,24 @@ public function push(Command $command): void $pipeline->process($command); } + + private function autowire(): void + { + $reflection = new ReflectionClass($this); + + if ($this->enqueuers instanceof EnqueuerContainer) { + foreach ($reflection->getAttributes(Queues::class) as $attribute) { + $instance = $attribute->newInstance(); + $this->enqueuers->bind($instance->command, $instance->enqueuer); + } + + foreach ($reflection->getAttributes(DefaultEnqueuer::class) as $attribute) { + $this->enqueuers->withDefault($attribute->newInstance()->enqueuer); + } + } + + foreach ($reflection->getAttributes(Through::class) as $attribute) { + $this->pipes = $attribute->newInstance()->pipes; + } + } } diff --git a/src/Infrastructure/Queue/DefaultEnqueuer.php b/src/Infrastructure/Queue/DefaultEnqueuer.php new file mode 100644 index 0000000..1ae67c2 --- /dev/null +++ b/src/Infrastructure/Queue/DefaultEnqueuer.php @@ -0,0 +1,26 @@ +, Closure> + * @var array, Closure|string> */ private array $bindings = []; /** - * @param Closure(): object $default + * @param (Closure(): object)|string|null $default */ - public function __construct(private readonly Closure $default) - { + public function __construct( + private Closure|string|null $default = null, + private readonly ?ContainerInterface $container = null, + ) { + if (is_string($this->default) && $this->container === null) { + throw new InfrastructureException( + 'Cannot bind default enqueuer as a string without a PSR container.', + ); + } } /** * Bind an enqueuer factory into the container. * * @param class-string $queueableName - * @param Closure(): object $binding + * @param (Closure(): object)|string $binding */ - public function bind(string $queueableName, Closure $binding): void + public function bind(string $queueableName, Closure|string $binding): void { $this->bindings[$queueableName] = $binding; } + public function withDefault(Closure|string $binding): void + { + if ($this->default !== null) { + throw new InfrastructureException('Default enqueuer is already set.'); + } + + if (is_string($binding) && $this->container === null) { + throw new InfrastructureException( + 'Cannot bind default enqueuer as a string without a PSR container.', + ); + } + + $this->default = $binding; + } + public function get(string $command): Enqueuer { - $factory = $this->bindings[$command] ?? $this->default; + $binding = $this->bindings[$command] ?? $this->default; - $enqueuer = $factory(); + if ($binding instanceof Closure) { + $instance = $binding(); + assert(is_object($instance), "Enqueuer binding for command {$command} must return an object."); + return new Enqueuer($instance); + } - assert(is_object($enqueuer), "Enqueuer binding for {$command} must return an object."); + if (is_string($binding)) { + $instance = $this->container?->get($binding); + assert(is_object($instance), "PSR container enqueuer binding {$binding} is not an object."); + return new Enqueuer($instance); + } - return new Enqueuer($enqueuer); + throw new InfrastructureException('No enqueuer bound for command: ' . $command); } } diff --git a/src/Infrastructure/Queue/Queues.php b/src/Infrastructure/Queue/Queues.php new file mode 100644 index 0000000..6977fa6 --- /dev/null +++ b/src/Infrastructure/Queue/Queues.php @@ -0,0 +1,28 @@ + $command + * @param class-string $enqueuer + */ + public function __construct(public string $command, public string $enqueuer) + { + } +} diff --git a/src/Testing/FakeContainer.php b/src/Testing/FakeContainer.php new file mode 100644 index 0000000..7c6564c --- /dev/null +++ b/src/Testing/FakeContainer.php @@ -0,0 +1,84 @@ +, 2025 + */ + +declare(strict_types=1); + +namespace CloudCreativity\Modules\Testing; + +use Closure; +use CloudCreativity\Modules\Contracts\Application\Ports\Driven\ExceptionReporter; +use CloudCreativity\Modules\Contracts\Application\Ports\Driven\UnitOfWork; +use Exception; +use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; +use Psr\Log\LoggerInterface; + +final class FakeContainer implements ContainerInterface +{ + public readonly FakeLogger $logger; + public readonly FakeExceptionReporter $reporter; + public readonly FakeUnitOfWork $unitOfWork; + + /** + * @var array + */ + private array $bindings = []; + + public function __construct() + { + $this->logger = new FakeLogger(); + $this->reporter = new FakeExceptionReporter(); + $this->unitOfWork = new FakeUnitOfWork($this->reporter); + } + + public function bind(string $id, Closure $binding): void + { + $this->bindings[$id] = $binding; + } + + public function instance(string $id, mixed $instance): void + { + $this->bindings[$id] = fn () => $instance; + } + + public function get(string $id) + { + $stub = match ($id) { + ExceptionReporter::class => $this->reporter, + LoggerInterface::class => $this->logger, + UnitOfWork::class => $this->unitOfWork, + default => null, + }; + + if ($stub === null) { + $binding = $this->bindings[$id] ?? null; + $stub = $binding ? $binding() : null; + } + + return $stub ?? $this->abort($id); + } + + public function has(string $id): bool + { + try { + $this->get($id); + return true; + } catch (NotFoundExceptionInterface) { + return false; + } + } + + protected function abort(string $id): never + { + $message = 'Test service not found: ' . $id; + + throw new class ($message) extends Exception implements NotFoundExceptionInterface {}; + } +} diff --git a/src/Testing/FakeLogger.php b/src/Testing/FakeLogger.php new file mode 100644 index 0000000..f46615b --- /dev/null +++ b/src/Testing/FakeLogger.php @@ -0,0 +1,53 @@ + + */ + public array $log = []; + + public function log($level, string|Stringable $message, array $context = []): void + { + $this->log[] = [ + 'level' => $level, + 'message' => (string) $message, + 'context' => $context, + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return array_map( + fn (array $entry): string => implode(': ', array_filter([ + is_scalar($entry['level']) ? strtoupper((string) $entry['level']) : null, + $entry['message'], + ])), + $this->log, + ); + } + + public function count(): int + { + return count($this->log); + } +} diff --git a/src/Testing/FakeUnitOfWork.php b/src/Testing/FakeUnitOfWork.php index a3ae33e..adbbbca 100644 --- a/src/Testing/FakeUnitOfWork.php +++ b/src/Testing/FakeUnitOfWork.php @@ -20,6 +20,12 @@ final class FakeUnitOfWork implements UnitOfWork { + public int $attempts = 0; + + public int $commits = 0; + + public int $rollbacks = 0; + /** * @var list */ @@ -38,11 +44,14 @@ public function execute(Closure $callback, int $attempts = 1): mixed for ($i = 1; $i <= $attempts; $i++) { try { $this->sequence[] = 'attempt:' . $i; + $this->attempts++; $result = $callback(); $this->sequence[] = 'commit:' . $i; + $this->commits++; return $result; } catch (Throwable $ex) { $this->sequence[] = 'rollback:' . $i; + $this->rollbacks++; $this->exceptions->report($ex); if ($i === $attempts) { diff --git a/src/Toolkit/Pipeline/PipeContainer.php b/src/Toolkit/Pipeline/PipeContainer.php index eff2526..96d403a 100644 --- a/src/Toolkit/Pipeline/PipeContainer.php +++ b/src/Toolkit/Pipeline/PipeContainer.php @@ -14,6 +14,7 @@ use Closure; use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer as IPipeContainer; +use Psr\Container\ContainerInterface; use RuntimeException; final class PipeContainer implements IPipeContainer @@ -23,6 +24,10 @@ final class PipeContainer implements IPipeContainer */ private array $pipes = []; + public function __construct(private readonly ?ContainerInterface $container = null) + { + } + /** * Bind a pipe into the container. */ @@ -37,7 +42,13 @@ public function get(string $pipeName): callable if (is_callable($factory)) { $pipe = $factory(); - assert(is_callable($pipe), 'Expecting pipe from factory to be callable.'); + assert(is_callable($pipe), "Expecting pipe {$pipeName} from factory to be callable."); + return $pipe; + } + + if ($this->container) { + $pipe = $this->container->get($pipeName); + assert(is_callable($pipe), "Expecting pipe {$pipeName} from PSR container to be callable."); return $pipe; } diff --git a/src/Toolkit/Pipeline/PipelineBuilder.php b/src/Toolkit/Pipeline/PipelineBuilder.php index 7b901e4..15daa1b 100644 --- a/src/Toolkit/Pipeline/PipelineBuilder.php +++ b/src/Toolkit/Pipeline/PipelineBuilder.php @@ -12,9 +12,10 @@ namespace CloudCreativity\Modules\Toolkit\Pipeline; -use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer; +use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer as IPipeContainer; use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipelineBuilder as IPipelineBuilder; use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\Processor; +use Psr\Container\ContainerInterface; use RuntimeException; final class PipelineBuilder implements IPipelineBuilder @@ -24,12 +25,16 @@ final class PipelineBuilder implements IPipelineBuilder */ private array $stages = []; - public static function make(?PipeContainer $container = null): self + public static function make(ContainerInterface|IPipeContainer|null $container = null): self { + if ($container instanceof ContainerInterface) { + return new self(new PipeContainer($container)); + } + return new self($container); } - public function __construct(private readonly ?PipeContainer $container = null) + public function __construct(private readonly ?IPipeContainer $container = null) { } diff --git a/src/Toolkit/Pipeline/Through.php b/src/Toolkit/Pipeline/Through.php new file mode 100644 index 0000000..090a7dc --- /dev/null +++ b/src/Toolkit/Pipeline/Through.php @@ -0,0 +1,32 @@ + + */ + public array $pipes; + + /** + * @param non-empty-string ...$pipes + */ + public function __construct(string ...$pipes) + { + $this->pipes = array_values($pipes); + } +} diff --git a/tests/Integration/Application/Bus/AddCommand.php b/tests/Integration/Application/Bus/AddCommand.php new file mode 100644 index 0000000..2596e23 --- /dev/null +++ b/tests/Integration/Application/Bus/AddCommand.php @@ -0,0 +1,22 @@ + + */ + public function execute(AddCommand $command): Result + { + return Result::ok($command->a + $command->b + $this->c); + } +} diff --git a/tests/Integration/Application/Bus/DivideQuery.php b/tests/Integration/Application/Bus/DivideQuery.php new file mode 100644 index 0000000..e439eba --- /dev/null +++ b/tests/Integration/Application/Bus/DivideQuery.php @@ -0,0 +1,22 @@ + + */ + public function execute(DivideQuery $query): Result + { + return Result::ok($query->a / $query->b); + } +} diff --git a/tests/Integration/Application/Bus/FloorCommand.php b/tests/Integration/Application/Bus/FloorCommand.php new file mode 100644 index 0000000..fc79999 --- /dev/null +++ b/tests/Integration/Application/Bus/FloorCommand.php @@ -0,0 +1,22 @@ +bind(AddCommandHandler::class, fn () => new AddCommandHandler(3)); + $container->bind(MultiplyCommandHandler::class, fn () => new MultiplyCommandHandler()); + $container->bind(LogMessageDispatch::class, fn () => new LogMessageDispatch($container->logger)); + $container->bind(ExecuteInUnitOfWork::class, fn () => new ExecuteInUnitOfWork( + new UnitOfWorkManager($container->unitOfWork), + )); + + $bus = new MathCommandBus($container); + + $add = $bus->dispatch(new AddCommand(1, 2)); + $multiply = $bus->dispatch(new MultiplyCommand(10, 11)); + + $this->assertSame(6, $add->value()); + $this->assertSame(110, $multiply->value()); + $this->assertCount(4, $container->logger); + $this->assertSame(1, $container->unitOfWork->commits); + } +} diff --git a/tests/Integration/Application/Bus/MathQueryBus.php b/tests/Integration/Application/Bus/MathQueryBus.php new file mode 100644 index 0000000..0cf831c --- /dev/null +++ b/tests/Integration/Application/Bus/MathQueryBus.php @@ -0,0 +1,25 @@ +bind(SubtractQueryHandler::class, fn () => new SubtractQueryHandler(3)); + $container->bind(DivideQueryHandler::class, fn () => new DivideQueryHandler()); + $container->bind(LogMessageDispatch::class, fn () => new LogMessageDispatch($container->logger)); + $container->bind('division-modifier', fn () => function (DivideQuery $query, Closure $next): mixed { + $query = new DivideQuery($query->a * 10, $query->b); + return $next($query); + }); + + $bus = new MathQueryBus($container); + + $add = $bus->dispatch(new SubtractQuery(1, 2)); + $multiply = $bus->dispatch(new DivideQuery(12, 3)); + + $this->assertSame(1 - 2 - 3, $add->value()); + $this->assertEquals(120 / 3, $multiply->value()); + $this->assertCount(4, $container->logger); + } +} diff --git a/tests/Integration/Application/Bus/MultiplyCommand.php b/tests/Integration/Application/Bus/MultiplyCommand.php new file mode 100644 index 0000000..fe63747 --- /dev/null +++ b/tests/Integration/Application/Bus/MultiplyCommand.php @@ -0,0 +1,22 @@ + + */ + public function execute(MultiplyCommand $command): Result + { + return Result::ok($command->a * $command->b); + } +} diff --git a/tests/Integration/Application/Bus/SubtractQuery.php b/tests/Integration/Application/Bus/SubtractQuery.php new file mode 100644 index 0000000..9983742 --- /dev/null +++ b/tests/Integration/Application/Bus/SubtractQuery.php @@ -0,0 +1,22 @@ + + */ + public function execute(SubtractQuery $query): Result + { + return Result::ok($query->a - $query->b - $this->c); + } +} diff --git a/tests/Integration/Application/InboundEventBus/DefaultHandler.php b/tests/Integration/Application/InboundEventBus/DefaultHandler.php new file mode 100644 index 0000000..73f6f21 --- /dev/null +++ b/tests/Integration/Application/InboundEventBus/DefaultHandler.php @@ -0,0 +1,28 @@ + + */ + public array $handled = []; + + public function handle(IntegrationEvent $event): void + { + $this->handled[] = $event; + } +} diff --git a/tests/Integration/Application/InboundEventBus/MathEventBus.php b/tests/Integration/Application/InboundEventBus/MathEventBus.php new file mode 100644 index 0000000..cd56b7d --- /dev/null +++ b/tests/Integration/Application/InboundEventBus/MathEventBus.php @@ -0,0 +1,27 @@ +bind(NumbersAddedHandler::class, fn () => $a); + $container->bind(NumbersSubtractedHandler::class, fn () => $b); + $container->bind(DefaultHandler::class, fn () => $c); + $container->bind(LogInboundEvent::class, fn () => new LogInboundEvent($container->logger)); + $container->bind(HandleInUnitOfWork::class, fn () => new HandleInUnitOfWork( + new UnitOfWorkManager($container->unitOfWork), + )); + + $bus = new MathEventBus($container); + + $bus->dispatch($ev1 = new NumbersAdded(1, 2, 3)); + $bus->dispatch($ev2 = new NumbersSubtracted(10, 6, 4)); + $bus->dispatch($ev3 = new NumbersDivided(12, 3, 4)); + + $this->assertSame([$ev1], $a->handled); + $this->assertSame([$ev2], $b->handled); + $this->assertSame([$ev3], $c->handled); + $this->assertCount(6, $container->logger); + $this->assertSame(1, $container->unitOfWork->commits); + } +} diff --git a/tests/Integration/Application/InboundEventBus/NumbersAdded.php b/tests/Integration/Application/InboundEventBus/NumbersAdded.php new file mode 100644 index 0000000..271af6d --- /dev/null +++ b/tests/Integration/Application/InboundEventBus/NumbersAdded.php @@ -0,0 +1,43 @@ +uuid = Uuid::random(); + } + + public function getUuid(): Uuid + { + return $this->uuid; + } + + public function getOccurredAt(): DateTimeImmutable + { + return $this->calculatedAt; + } +} diff --git a/tests/Integration/Application/InboundEventBus/NumbersAddedHandler.php b/tests/Integration/Application/InboundEventBus/NumbersAddedHandler.php new file mode 100644 index 0000000..686dd94 --- /dev/null +++ b/tests/Integration/Application/InboundEventBus/NumbersAddedHandler.php @@ -0,0 +1,26 @@ + + */ + public array $handled = []; + + public function handle(NumbersAdded $event): void + { + $this->handled[] = $event; + } +} diff --git a/tests/Integration/Application/InboundEventBus/NumbersDivided.php b/tests/Integration/Application/InboundEventBus/NumbersDivided.php new file mode 100644 index 0000000..020aae9 --- /dev/null +++ b/tests/Integration/Application/InboundEventBus/NumbersDivided.php @@ -0,0 +1,43 @@ +uuid = Uuid::random(); + } + + public function getUuid(): Uuid + { + return $this->uuid; + } + + public function getOccurredAt(): DateTimeImmutable + { + return $this->calculatedAt; + } +} diff --git a/tests/Integration/Application/InboundEventBus/NumbersSubtracted.php b/tests/Integration/Application/InboundEventBus/NumbersSubtracted.php new file mode 100644 index 0000000..112fd56 --- /dev/null +++ b/tests/Integration/Application/InboundEventBus/NumbersSubtracted.php @@ -0,0 +1,43 @@ +uuid = Uuid::random(); + } + + public function getUuid(): Uuid + { + return $this->uuid; + } + + public function getOccurredAt(): DateTimeImmutable + { + return $this->calculatedAt; + } +} diff --git a/tests/Integration/Application/InboundEventBus/NumbersSubtractedHandler.php b/tests/Integration/Application/InboundEventBus/NumbersSubtractedHandler.php new file mode 100644 index 0000000..e298768 --- /dev/null +++ b/tests/Integration/Application/InboundEventBus/NumbersSubtractedHandler.php @@ -0,0 +1,30 @@ + + */ + public array $handled = []; + + public function handle(NumbersSubtracted $event): void + { + $this->handled[] = $event; + } +} diff --git a/tests/Integration/Infrastructure/OutboundEventBus/MathEventPublisher.php b/tests/Integration/Infrastructure/OutboundEventBus/MathEventPublisher.php new file mode 100644 index 0000000..7fa6d56 --- /dev/null +++ b/tests/Integration/Infrastructure/OutboundEventBus/MathEventPublisher.php @@ -0,0 +1,29 @@ +bind(NumbersAddedPublisher::class, fn () => $a); + $container->bind(NumbersSubtractedPublisher::class, fn () => $b); + $container->bind(TestDefaultPublisher::class, fn () => $c); + $container->bind(LogOutboundEvent::class, fn () => new LogOutboundEvent($container->logger)); + + $publisher = new MathEventPublisher($container); + $publisher->publish($ev1 = new NumbersAdded(1, 2, 3)); + $publisher->publish($ev2 = new NumbersSubtracted(10, 6, 4)); + $publisher->publish($ev3 = new NumbersDivided(12, 3, 4)); + + $this->assertSame([$ev1], $a->published); + $this->assertSame([$ev2], $b->published); + $this->assertSame([$ev3], $c->published); + $this->assertCount(6, $container->logger); + } +} diff --git a/tests/Integration/Infrastructure/OutboundEventBus/NumbersAddedPublisher.php b/tests/Integration/Infrastructure/OutboundEventBus/NumbersAddedPublisher.php new file mode 100644 index 0000000..c3f97a8 --- /dev/null +++ b/tests/Integration/Infrastructure/OutboundEventBus/NumbersAddedPublisher.php @@ -0,0 +1,28 @@ + + */ + public array $published = []; + + public function publish(NumbersAdded $event): void + { + $this->published[] = $event; + } +} diff --git a/tests/Integration/Infrastructure/OutboundEventBus/NumbersSubtractedPublisher.php b/tests/Integration/Infrastructure/OutboundEventBus/NumbersSubtractedPublisher.php new file mode 100644 index 0000000..f9379a7 --- /dev/null +++ b/tests/Integration/Infrastructure/OutboundEventBus/NumbersSubtractedPublisher.php @@ -0,0 +1,28 @@ + + */ + public array $published = []; + + public function publish(NumbersSubtracted $event): void + { + $this->published[] = $event; + } +} diff --git a/tests/Integration/Infrastructure/OutboundEventBus/TestClosurePublisher.php b/tests/Integration/Infrastructure/OutboundEventBus/TestClosurePublisher.php new file mode 100644 index 0000000..3497b9f --- /dev/null +++ b/tests/Integration/Infrastructure/OutboundEventBus/TestClosurePublisher.php @@ -0,0 +1,22 @@ +bind(LogOutboundEvent::class, fn () => new LogOutboundEvent($container->logger)); + + $publisher = new TestClosurePublisher( + fn: function ($event) use (&$published) { + $published[] = $event; + }, + middleware: $container, + ); + + $event = new NumbersAdded(1, 2, 3); + $publisher->publish($event); + + $this->assertSame([$event], $published); + $this->assertCount(2, $container->logger); + } +} diff --git a/tests/Integration/Infrastructure/OutboundEventBus/TestDefaultPublisher.php b/tests/Integration/Infrastructure/OutboundEventBus/TestDefaultPublisher.php new file mode 100644 index 0000000..36b1a21 --- /dev/null +++ b/tests/Integration/Infrastructure/OutboundEventBus/TestDefaultPublisher.php @@ -0,0 +1,28 @@ + + */ + public array $published = []; + + public function publish(IntegrationEvent $event): void + { + $this->published[] = $event; + } +} diff --git a/tests/Integration/Infrastructure/Queue/AddCommandEnqueuer.php b/tests/Integration/Infrastructure/Queue/AddCommandEnqueuer.php new file mode 100644 index 0000000..5bb7646 --- /dev/null +++ b/tests/Integration/Infrastructure/Queue/AddCommandEnqueuer.php @@ -0,0 +1,28 @@ + + */ + public array $queued = []; + + public function push(AddCommand $command): void + { + $this->queued[] = $command; + } +} diff --git a/tests/Integration/Infrastructure/Queue/MathQueue.php b/tests/Integration/Infrastructure/Queue/MathQueue.php new file mode 100644 index 0000000..4cd8fb0 --- /dev/null +++ b/tests/Integration/Infrastructure/Queue/MathQueue.php @@ -0,0 +1,29 @@ +bind(AddCommandEnqueuer::class, fn () => $a); + $container->bind(MultiplyCommandEnqueuer::class, fn () => $b); + $container->bind(TestDefaultEnqueuer::class, fn () => $c); + $container->bind(LogPushedToQueue::class, fn () => new LogPushedToQueue($container->logger)); + + $publisher = new MathQueue($container); + $publisher->push($command1 = new AddCommand(1, 2)); + $publisher->push($command2 = new MultiplyCommand(10, 6)); + $publisher->push($command3 = new FloorCommand(99.9)); + + $this->assertSame([$command1], $a->queued); + $this->assertSame([$command2], $b->queued); + $this->assertSame([$command3], $c->queued); + $this->assertCount(6, $container->logger); + } +} diff --git a/tests/Integration/Infrastructure/Queue/MultiplyCommandEnqueuer.php b/tests/Integration/Infrastructure/Queue/MultiplyCommandEnqueuer.php new file mode 100644 index 0000000..d2f9ee9 --- /dev/null +++ b/tests/Integration/Infrastructure/Queue/MultiplyCommandEnqueuer.php @@ -0,0 +1,28 @@ + + */ + public array $queued = []; + + public function push(MultiplyCommand $command): void + { + $this->queued[] = $command; + } +} diff --git a/tests/Integration/Infrastructure/Queue/TestClosureQueue.php b/tests/Integration/Infrastructure/Queue/TestClosureQueue.php new file mode 100644 index 0000000..d690220 --- /dev/null +++ b/tests/Integration/Infrastructure/Queue/TestClosureQueue.php @@ -0,0 +1,22 @@ +bind(LogPushedToQueue::class, fn () => new LogPushedToQueue($container->logger)); + + $publisher = new TestClosureQueue( + fn: function ($event) use (&$queued) { + $queued[] = $event; + }, + middleware: $container, + ); + + $command = new AddCommand(1, 2); + $publisher->push($command); + + $this->assertSame([$command], $queued); + $this->assertCount(2, $container->logger); + } +} diff --git a/tests/Integration/Infrastructure/Queue/TestDefaultEnqueuer.php b/tests/Integration/Infrastructure/Queue/TestDefaultEnqueuer.php new file mode 100644 index 0000000..6bec9c3 --- /dev/null +++ b/tests/Integration/Infrastructure/Queue/TestDefaultEnqueuer.php @@ -0,0 +1,28 @@ + + */ + public array $queued = []; + + public function push(Command $command): void + { + $this->queued[] = $command; + } +} diff --git a/tests/Unit/Application/Bus/CommandHandlerContainerTest.php b/tests/Unit/Application/Bus/CommandHandlerContainerTest.php index d14d64a..7bacf1d 100644 --- a/tests/Unit/Application/Bus/CommandHandlerContainerTest.php +++ b/tests/Unit/Application/Bus/CommandHandlerContainerTest.php @@ -17,13 +17,14 @@ use CloudCreativity\Modules\Application\Bus\CommandHandlerContainer; use CloudCreativity\Modules\Contracts\Toolkit\Messages\Command; use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; class CommandHandlerContainerTest extends TestCase { - public function test(): void + public function testItResolvesUsingClosureBindings(): void { $a = new TestCommandHandler(); - $b = $this->createMock(TestCommandHandler::class); + $b = $this->createStub(TestCommandHandler::class); $command1 = new class () implements Command {}; $command2 = new class () implements Command {}; @@ -41,4 +42,36 @@ public function test(): void $container->get($command3::class); } + + public function testItResolvesViaPsrContainer(): void + { + $a = new TestCommandHandler(); + $b = $this->createStub(TestCommandHandler::class); + + $command1 = new class () implements Command {}; + $command2 = new class () implements Command {}; + $command3 = new class () implements Command {}; + + $psrContainer = $this->createMock(ContainerInterface::class); + $psrContainer + ->expects($this->exactly(2)) + ->method('get') + ->willReturnCallback(fn (string $id) => match ($id) { + $a::class => $a, + $b::class => $b, + default => $this->fail('Unexpected container id: ' . $id), + }); + + $container = new CommandHandlerContainer($psrContainer); + $container->bind($command1::class, $a::class); + $container->bind($command2::class, $b::class); + + $this->assertEquals(new CommandHandler($a), $container->get($command1::class)); + $this->assertEquals(new CommandHandler($b), $container->get($command2::class)); + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('No command handler bound for command class: ' . $command3::class); + + $container->get($command3::class); + } } diff --git a/tests/Unit/Application/Bus/QueryHandlerContainerTest.php b/tests/Unit/Application/Bus/QueryHandlerContainerTest.php index 2348927..23ac92b 100644 --- a/tests/Unit/Application/Bus/QueryHandlerContainerTest.php +++ b/tests/Unit/Application/Bus/QueryHandlerContainerTest.php @@ -17,13 +17,14 @@ use CloudCreativity\Modules\Application\Bus\QueryHandlerContainer; use CloudCreativity\Modules\Contracts\Toolkit\Messages\Query; use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; class QueryHandlerContainerTest extends TestCase { - public function test(): void + public function testItResolvesClosureBindings(): void { $a = new TestQueryHandler(); - $b = $this->createMock(TestQueryHandler::class); + $b = $this->createStub(TestQueryHandler::class); $query1 = new class () implements Query {}; $query2 = new class () implements Query {}; @@ -41,4 +42,36 @@ public function test(): void $container->get($query3::class); } + + public function testItResolvesViaPsrContainer(): void + { + $a = new TestQueryHandler(); + $b = $this->createStub(TestQueryHandler::class); + + $query1 = new class () implements Query {}; + $query2 = new class () implements Query {}; + $query3 = new class () implements Query {}; + + $psrContainer = $this->createMock(ContainerInterface::class); + $psrContainer + ->expects($this->exactly(2)) + ->method('get') + ->willReturnCallback(fn (string $id) => match ($id) { + $a::class => $a, + $b::class => $b, + default => $this->fail('Unexpected container id: ' . $id), + }); + + $container = new QueryHandlerContainer($psrContainer); + $container->bind($query1::class, $a::class); + $container->bind($query2::class, $b::class); + + $this->assertEquals(new QueryHandler($a), $container->get($query1::class)); + $this->assertEquals(new QueryHandler($b), $container->get($query2::class)); + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('No query handler bound for query class: ' . $query3::class); + + $container->get($query3::class); + } } diff --git a/tests/Unit/Application/InboundEventBus/EventHandlerContainerTest.php b/tests/Unit/Application/InboundEventBus/EventHandlerContainerTest.php index 335520e..f82069d 100644 --- a/tests/Unit/Application/InboundEventBus/EventHandlerContainerTest.php +++ b/tests/Unit/Application/InboundEventBus/EventHandlerContainerTest.php @@ -12,17 +12,19 @@ namespace CloudCreativity\Modules\Tests\Unit\Application\InboundEventBus; +use CloudCreativity\Modules\Application\ApplicationException; use CloudCreativity\Modules\Application\InboundEventBus\EventHandler; use CloudCreativity\Modules\Application\InboundEventBus\EventHandlerContainer; use CloudCreativity\Modules\Tests\Unit\Infrastructure\OutboundEventBus\TestOutboundEvent; use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; class EventHandlerContainerTest extends TestCase { - public function testItHasHandlers(): void + public function testItHasHandlerBindings(): void { $a = new TestEventHandler(); - $b = $this->createMock(TestEventHandler::class); + $b = $this->createStub(TestEventHandler::class); $container = new EventHandlerContainer(); $container->bind(TestInboundEvent::class, fn () => $a); @@ -32,10 +34,33 @@ public function testItHasHandlers(): void $this->assertEquals(new EventHandler($b), $container->get(TestOutboundEvent::class)); } - public function testItHasDefaultHandler(): void + public function testItUsesPsrContainer(): void { $a = new TestEventHandler(); - $b = $this->createMock(TestEventHandler::class); + $b = $this->createStub(TestEventHandler::class); + + $psrContainer = $this->createMock(ContainerInterface::class); + $psrContainer + ->expects($this->exactly(2)) + ->method('get') + ->willReturnCallback(fn (string $class) => match ($class) { + $a::class => $a, + $b::class => $b, + default => $this->fail('Unexpected class requested: ' . $class), + }); + + $container = new EventHandlerContainer(container: $psrContainer); + $container->bind(TestInboundEvent::class, $a::class); + $container->bind(TestOutboundEvent::class, $b::class); + + $this->assertEquals(new EventHandler($a), $container->get(TestInboundEvent::class)); + $this->assertEquals(new EventHandler($b), $container->get(TestOutboundEvent::class)); + } + + public function testItHasBoundDefaultHandler(): void + { + $a = new TestEventHandler(); + $b = $this->createStub(TestEventHandler::class); $container = new EventHandlerContainer(default: fn () => $b); $container->bind(TestInboundEvent::class, fn () => $a); @@ -44,13 +69,32 @@ public function testItHasDefaultHandler(): void $this->assertEquals(new EventHandler($b), $container->get(TestOutboundEvent::class)); } + public function testItHasDefaultHandlerInPsrContainer(): void + { + $a = new TestEventHandler(); + $b = $this->createStub(TestEventHandler::class); + + $psrContainer = $this->createMock(ContainerInterface::class); + $psrContainer + ->expects($this->once()) + ->method('get') + ->with('default-handler') + ->willReturn($b); + + $container = new EventHandlerContainer(default: 'default-handler', container: $psrContainer); + $container->bind(TestInboundEvent::class, fn () => $a); + + $this->assertEquals(new EventHandler($a), $container->get(TestInboundEvent::class)); + $this->assertEquals(new EventHandler($b), $container->get(TestOutboundEvent::class)); + } + public function testItDoesNotHaveHandler(): void { $container = new EventHandlerContainer(); $container->bind(TestInboundEvent::class, fn () => new TestEventHandler()); - $this->expectException(\RuntimeException::class); + $this->expectException(ApplicationException::class); $this->expectExceptionMessage( 'No handler bound for integration event: ' . TestOutboundEvent::class, ); diff --git a/tests/Unit/Infrastructure/OutboundEventBus/ClosurePublisherTest.php b/tests/Unit/Infrastructure/OutboundEventBus/ClosurePublisherTest.php index 338d799..bf8366c 100644 --- a/tests/Unit/Infrastructure/OutboundEventBus/ClosurePublisherTest.php +++ b/tests/Unit/Infrastructure/OutboundEventBus/ClosurePublisherTest.php @@ -13,55 +13,40 @@ namespace CloudCreativity\Modules\Tests\Unit\Infrastructure\OutboundEventBus; use CloudCreativity\Modules\Contracts\Toolkit\Messages\IntegrationEvent; -use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer; +use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer as IPipeContainer; use CloudCreativity\Modules\Infrastructure\OutboundEventBus\ClosurePublisher; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; class ClosurePublisherTest extends TestCase { - private MockObject&PipeContainer $middleware; - /** * @var array */ private array $actual = []; - private ClosurePublisher $publisher; - - protected function setUp(): void - { - parent::setUp(); - - $this->publisher = new ClosurePublisher( - function (IntegrationEvent $event): void { - $this->actual[] = $event; - }, - $this->middleware = $this->createMock(PipeContainer::class), - ); - } - protected function tearDown(): void { parent::tearDown(); - unset($this->publisher, $this->middleware, $this->actual); + unset($this->actual); } public function test(): void { - $event = $this->createMock(IntegrationEvent::class); + $event = $this->createStub(IntegrationEvent::class); - $this->publisher->publish($event); + $publisher = $this->createPublisher(); + $publisher->publish($event); $this->assertSame([$event], $this->actual); } public function testWithMiddleware(): void { - $event1 = $this->createMock(IntegrationEvent::class); - $event2 = $this->createMock(IntegrationEvent::class); - $event3 = $this->createMock(IntegrationEvent::class); - $event4 = $this->createMock(IntegrationEvent::class); + $event1 = $this->createStub(IntegrationEvent::class); + $event2 = $this->createStub(IntegrationEvent::class); + $event3 = $this->createStub(IntegrationEvent::class); + $event4 = $this->createStub(IntegrationEvent::class); $middleware1 = function ($event, \Closure $next) use ($event1, $event2) { $this->assertSame($event1, $event); @@ -78,44 +63,92 @@ public function testWithMiddleware(): void return $next($event4); }; - $this->middleware + $middleware = $this->createMock(IPipeContainer::class); + $middleware ->expects($this->once()) ->method('get') ->with('MySecondMiddleware') ->willReturn($middleware2); - $this->publisher->through([ + $publisher = $this->createPublisher($middleware); + $publisher->through([ $middleware1, 'MySecondMiddleware', $middleware3, ]); - $this->publisher->publish($event1); + $publisher->publish($event1); $this->assertSame([$event4], $this->actual); } + public function testWithMiddlewareViaPsrContainer(): void + { + $event1 = $this->createStub(IntegrationEvent::class); + $event2 = $this->createStub(IntegrationEvent::class); + $event3 = $this->createStub(IntegrationEvent::class); + + $middleware1 = function ($event, \Closure $next) use ($event1, $event2) { + $this->assertSame($event1, $event); + return $next($event2); + }; + + $middleware2 = function ($event, \Closure $next) use ($event2, $event3) { + $this->assertSame($event2, $event); + return $next($event3); + }; + + $psrContainer = $this->createMock(ContainerInterface::class); + $psrContainer + ->expects($this->once()) + ->method('get') + ->with('MySecondMiddleware') + ->willReturn($middleware2); + + $publisher = $this->createPublisher($psrContainer); + $publisher->through([ + $middleware1, + 'MySecondMiddleware', + ]); + + $publisher->publish($event1); + + $this->assertSame([$event3], $this->actual); + } + public function testWithAlternativeHandlers(): void { $expected = new TestOutboundEvent(); - $mock = $this->createMock(IntegrationEvent::class); + $stub = $this->createStub(IntegrationEvent::class); $actual = null; - $this->publisher->bind($mock::class, function (): never { + $publisher = $this->createPublisher(); + + $publisher->bind($stub::class, function (): never { $this->fail('Not expecting this closure to be called.'); }); - $this->publisher->bind( + $publisher->bind( TestOutboundEvent::class, function (TestOutboundEvent $in) use (&$actual) { $actual = $in; }, ); - $this->publisher->publish($expected); + $publisher->publish($expected); $this->assertEmpty($this->actual); $this->assertSame($expected, $actual); } + + private function createPublisher(ContainerInterface|IPipeContainer|null $middleware = null): ClosurePublisher + { + return new ClosurePublisher( + function (IntegrationEvent $event): void { + $this->actual[] = $event; + }, + $middleware, + ); + } } diff --git a/tests/Unit/Infrastructure/OutboundEventBus/PublisherHandlerContainerTest.php b/tests/Unit/Infrastructure/OutboundEventBus/PublisherHandlerContainerTest.php index 009bc0d..3c07902 100644 --- a/tests/Unit/Infrastructure/OutboundEventBus/PublisherHandlerContainerTest.php +++ b/tests/Unit/Infrastructure/OutboundEventBus/PublisherHandlerContainerTest.php @@ -15,14 +15,15 @@ use CloudCreativity\Modules\Infrastructure\OutboundEventBus\PublisherHandler; use CloudCreativity\Modules\Infrastructure\OutboundEventBus\PublisherHandlerContainer; use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; use RuntimeException; class PublisherHandlerContainerTest extends TestCase { - public function testItDoesNotHaveDefaultHandler(): void + public function testItPublishesViaBindingsWithoutDefaultHandler(): void { $a = new TestPublisher(); - $b = $this->createMock(TestPublisher::class); + $b = $this->createStub(TestPublisher::class); $event1 = new class () extends TestOutboundEvent {}; $event2 = new class () extends TestOutboundEvent {}; @@ -41,10 +42,10 @@ public function testItDoesNotHaveDefaultHandler(): void $container->get($event3::class); } - public function testItHasDefaultHandler(): void + public function testItPublishesViaBindingsWithDefaultHandler(): void { $a = new TestPublisher(); - $b = $this->createMock(TestPublisher::class); + $b = $this->createStub(TestPublisher::class); $event1 = new class () extends TestOutboundEvent {}; $event2 = new class () extends TestOutboundEvent {}; @@ -57,4 +58,63 @@ public function testItHasDefaultHandler(): void $this->assertEquals($default = new PublisherHandler($b), $container->get($event2::class)); $this->assertEquals($default, $container->get($event3::class)); } + + public function testItPublishesViaPsrContainerWithoutDefaultHandler(): void + { + $a = new TestPublisher(); + $b = $this->createStub(TestPublisher::class); + + $event1 = new class () extends TestOutboundEvent {}; + $event2 = new class () extends TestOutboundEvent {}; + $event3 = new class () extends TestOutboundEvent {}; + + $psrContainer = $this->createMock(ContainerInterface::class); + $psrContainer + ->expects($this->exactly(2)) + ->method('get') + ->willReturnCallback(fn (string $id) => match ($id) { + $a::class => $a, + $b::class => $b, + default => $this->fail('Unexpected container id: ' . $id), + }); + + $container = new PublisherHandlerContainer(container: $psrContainer); + $container->bind($event1::class, $a::class); + $container->bind($event2::class, $b::class); + + $this->assertEquals(new PublisherHandler($a), $container->get($event1::class)); + $this->assertEquals(new PublisherHandler($b), $container->get($event2::class)); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No handler bound for integration event: ' . $event3::class); + + $container->get($event3::class); + } + + public function testItPublishesViaPsrContainerWithDefaultHandler(): void + { + $a = new TestPublisher(); + $b = $this->createStub(TestPublisher::class); + + $event1 = new class () extends TestOutboundEvent {}; + $event2 = new class () extends TestOutboundEvent {}; + $event3 = new class () extends TestOutboundEvent {}; + + $psrContainer = $this->createMock(ContainerInterface::class); + $psrContainer + ->expects($this->exactly(3)) + ->method('get') + ->willReturnCallback(fn (string $id) => match ($id) { + $a::class => $a, + $b::class => $b, + default => $this->fail('Unexpected container id: ' . $id), + }); + + $container = new PublisherHandlerContainer(default: $b::class, container: $psrContainer); + $container->bind($event1::class, $a::class); + + $this->assertEquals(new PublisherHandler($a), $container->get($event1::class)); + $this->assertEquals($default = new PublisherHandler($b), $container->get($event2::class)); + $this->assertEquals($default, $container->get($event3::class)); + } } diff --git a/tests/Unit/Infrastructure/Queue/EnqueuerContainerTest.php b/tests/Unit/Infrastructure/Queue/EnqueuerContainerTest.php index 6226e78..31757c4 100644 --- a/tests/Unit/Infrastructure/Queue/EnqueuerContainerTest.php +++ b/tests/Unit/Infrastructure/Queue/EnqueuerContainerTest.php @@ -17,17 +17,18 @@ use CloudCreativity\Modules\Infrastructure\Queue\EnqueuerContainer; use CloudCreativity\Modules\Tests\Unit\Application\Bus\TestCommand; use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; class EnqueuerContainerTest extends TestCase { - public function test(): void + public function testItUsesBindings(): void { $command1 = new class () implements Command {}; $command2 = new class () implements Command {}; $a = new TestEnqueuer(); - $b = $this->createMock(TestEnqueuer::class); - $default = $this->createMock(TestEnqueuer::class); + $b = $this->createStub(TestEnqueuer::class); + $default = $this->createStub(TestEnqueuer::class); $container = new EnqueuerContainer(fn () => $default); $container->bind($command1::class, fn () => $a); @@ -37,4 +38,33 @@ public function test(): void $this->assertEquals(new Enqueuer($b), $container->get($command2::class)); $this->assertEquals(new Enqueuer($default), $container->get(TestCommand::class)); } + + public function testItUsesPsrContainer(): void + { + $command1 = new class () implements Command {}; + $command2 = new class () implements Command {}; + + $a = new TestEnqueuer(); + $b = $this->createStub(TestEnqueuer::class); + $default = $this->createStub(TestEnqueuer::class); + + $psrContainer = $this->createMock(ContainerInterface::class); + $psrContainer + ->expects($this->exactly(3)) + ->method('get') + ->willReturnCallback(fn (string $id) => match ($id) { + $a::class => $a, + $b::class => $b, + $default::class => $default, + default => $this->fail('Unexpected binding: ' . $id), + }); + + $container = new EnqueuerContainer(default: $default::class, container: $psrContainer); + $container->bind($command1::class, $a::class); + $container->bind($command2::class, $b::class); + + $this->assertEquals(new Enqueuer($a), $container->get($command1::class)); + $this->assertEquals(new Enqueuer($b), $container->get($command2::class)); + $this->assertEquals(new Enqueuer($default), $container->get(TestCommand::class)); + } } diff --git a/tests/Unit/Testing/FakeUnitOfWorkTest.php b/tests/Unit/Testing/FakeUnitOfWorkTest.php index 3889bf3..d5bceab 100644 --- a/tests/Unit/Testing/FakeUnitOfWorkTest.php +++ b/tests/Unit/Testing/FakeUnitOfWorkTest.php @@ -28,6 +28,9 @@ public function testItIsSuccessfulOnFirstAttempt(): void $this->assertSame('result', $result); $this->assertSame(['attempt:1', 'commit:1'], $unitOfWork->sequence); $this->assertEmpty($unitOfWork->exceptions->reported); + $this->assertSame(1, $unitOfWork->attempts); + $this->assertSame(1, $unitOfWork->commits); + $this->assertSame(0, $unitOfWork->rollbacks); } public function testItIsSuccessfulBeforeMaxAttempts(): void @@ -56,6 +59,9 @@ public function testItIsSuccessfulBeforeMaxAttempts(): void 'commit:3', ], $unitOfWork->sequence); $this->assertSame($unitOfWork->exceptions->reported, [$ex1, $ex2]); + $this->assertSame(3, $unitOfWork->attempts); + $this->assertSame(1, $unitOfWork->commits); + $this->assertSame(2, $unitOfWork->rollbacks); } public function testItIsSuccessfulOnMaxAttempts(): void @@ -84,6 +90,9 @@ public function testItIsSuccessfulOnMaxAttempts(): void 'commit:3', ], $unitOfWork->sequence); $this->assertSame($unitOfWork->exceptions->reported, [$ex1, $ex2]); + $this->assertSame(3, $unitOfWork->attempts); + $this->assertSame(1, $unitOfWork->commits); + $this->assertSame(2, $unitOfWork->rollbacks); } public function testItIsNotSuccessful(): void @@ -108,6 +117,9 @@ public function testItIsNotSuccessful(): void 'rollback:2', ], $unitOfWork->sequence); $this->assertSame($unitOfWork->exceptions->reported, [$ex1, $ex2]); + $this->assertSame(2, $unitOfWork->attempts); + $this->assertSame(0, $unitOfWork->commits); + $this->assertSame(2, $unitOfWork->rollbacks); } } } diff --git a/tests/Unit/Toolkit/Pipeline/PipeContainerTest.php b/tests/Unit/Toolkit/Pipeline/PipeContainerTest.php index d2ca32a..6da2fea 100644 --- a/tests/Unit/Toolkit/Pipeline/PipeContainerTest.php +++ b/tests/Unit/Toolkit/Pipeline/PipeContainerTest.php @@ -14,10 +14,11 @@ use CloudCreativity\Modules\Toolkit\Pipeline\PipeContainer; use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; class PipeContainerTest extends TestCase { - public function test(): void + public function testItResolvesBoundPipes(): void { $a = fn () => 1; $b = fn () => 2; @@ -34,4 +35,27 @@ public function test(): void $container->get('PipeC'); } + + public function testItFallsBackToPsrContainer(): void + { + $psrContainer = $this->createMock(ContainerInterface::class); + + $a = fn () => 1; + $b = fn () => 2; + $c = fn () => 3; + + $container = new PipeContainer($psrContainer); + $container->bind('PipeA', fn () => $a); + $container->bind('PipeB', fn () => $b); + + $psrContainer + ->expects($this->once()) + ->method('get') + ->with('PipeC') + ->willReturn($c); + + $this->assertSame($a, $container->get('PipeA')); + $this->assertSame($b, $container->get('PipeB')); + $this->assertSame($c, $container->get('PipeC')); + } }