diff --git a/src/Application/Security/Usecases/CreateClient/CreateClient.php b/src/Application/Security/Usecases/CreateClient/CreateClient.php index 233845a..c660c0e 100644 --- a/src/Application/Security/Usecases/CreateClient/CreateClient.php +++ b/src/Application/Security/Usecases/CreateClient/CreateClient.php @@ -13,11 +13,12 @@ use Application\Security\Ports\Services\ClientCredentialGenerator; use Ramsey\Uuid\Uuid; use Shared\DDD\Event\DomainEventDispatcher; use Shared\DDD\Usecase\Input as BaseInput; +use Shared\DDD\Usecase\Transactional; use Shared\DDD\Usecase\Usecase; use Webmozart\Assert\Assert; /** @implements Usecase<\Application\Security\Usecases\CreateClient\Input, \Application\Security\Usecases\CreateClient\Output> */ -final readonly class CreateClient implements Usecase +final readonly class CreateClient implements Transactional, Usecase { public function __construct( private ClientRepository $clientRepository, diff --git a/src/Application/Security/Usecases/RegenerateClientSecret/RegenerateClientSecret.php b/src/Application/Security/Usecases/RegenerateClientSecret/RegenerateClientSecret.php index fc21382..1b1947a 100644 --- a/src/Application/Security/Usecases/RegenerateClientSecret/RegenerateClientSecret.php +++ b/src/Application/Security/Usecases/RegenerateClientSecret/RegenerateClientSecret.php @@ -10,11 +10,12 @@ use Application\Security\Ports\Repositories\ClientRepository; use Application\Security\Ports\Services\ClientCredentialGenerator; use Shared\DDD\Event\DomainEventDispatcher; use Shared\DDD\Usecase\Input as BaseInput; +use Shared\DDD\Usecase\Transactional; use Shared\DDD\Usecase\Usecase; use Webmozart\Assert\Assert; /** @implements Usecase<\Application\Security\Usecases\RegenerateClientSecret\Input, \Application\Security\Usecases\RegenerateClientSecret\Output> */ -final readonly class RegenerateClientSecret implements Usecase +final readonly class RegenerateClientSecret implements Transactional, Usecase { public function __construct( private ClientRepository $clientRepository, diff --git a/src/Application/Security/Usecases/ToggleClientStatus/SetClientStatus.php b/src/Application/Security/Usecases/ToggleClientStatus/SetClientStatus.php index c733341..7686c4f 100644 --- a/src/Application/Security/Usecases/ToggleClientStatus/SetClientStatus.php +++ b/src/Application/Security/Usecases/ToggleClientStatus/SetClientStatus.php @@ -9,11 +9,12 @@ use Application\Security\Exceptions\ClientNotFoundException; use Application\Security\Ports\Repositories\ClientRepository; use Shared\DDD\Event\DomainEventDispatcher; use Shared\DDD\Usecase\Input as BaseInput; +use Shared\DDD\Usecase\Transactional; use Shared\DDD\Usecase\Usecase; use Webmozart\Assert\Assert; /** @implements Usecase<\Application\Security\Usecases\ToggleClientStatus\Input, \Application\Security\Usecases\ToggleClientStatus\Output> */ -final readonly class SetClientStatus implements Usecase +final readonly class SetClientStatus implements Transactional, Usecase { public function __construct( private ClientRepository $clientRepository, diff --git a/src/Infra/Usecase/LaravelExecutor.php b/src/Infra/Usecase/LaravelExecutor.php index 2d262f1..4251f90 100644 --- a/src/Infra/Usecase/LaravelExecutor.php +++ b/src/Infra/Usecase/LaravelExecutor.php @@ -9,7 +9,9 @@ use Psr\Log\LoggerInterface; use Shared\DDD\Security\UserDetailsProvider; use Shared\DDD\Usecase\Builder\UsecaseExecutor; use Shared\DDD\Usecase\Input; +use Shared\DDD\Usecase\Output; use Shared\DDD\Usecase\Presenter; +use Shared\DDD\Usecase\Transactional; use Shared\DDD\Usecase\Usecase; final readonly class LaravelExecutor implements UsecaseExecutor @@ -28,19 +30,20 @@ final readonly class LaravelExecutor implements UsecaseExecutor * * @param Usecase $usecase * @param TInput $input - * @param Presenter $presenter - * @return TView + * @param Presenter|null $presenter + * @return ($presenter is null ? TOutput : TView) */ #[\Override] - public function execute(Usecase $usecase, Input $input, Presenter $presenter): object + public function execute(Usecase $usecase, Input $input, ?Presenter $presenter = null): object { $currentUser = $this->userDetailsProvider?->currentUser(); $context = [ - 'usecase' => $usecase::class, - 'input' => $input::class, - 'presenter' => $presenter::class, - 'user' => $currentUser ? [ + 'usecase' => $usecase::class, + 'input' => $input::class, + 'presenter' => $presenter ? $presenter::class : 'none', + 'transactional' => $usecase instanceof Transactional, + 'user' => $currentUser ? [ 'id' => $currentUser->id(), 'name' => $currentUser->name(), 'type' => $currentUser->type()->type(), @@ -51,16 +54,21 @@ final readonly class LaravelExecutor implements UsecaseExecutor $this->logger->debug('Starting usecase execution', $context); try { - $result = $this->database->transaction(function () use ($usecase, $input, $presenter): object { + if ($usecase instanceof Transactional) { + $output = $this->database->transaction(fn (): Output => $usecase->execute($input)); + } else { $output = $usecase->execute($input); - - return $presenter->present($output); - }); + } $this->logger->info('Usecase executed successfully', $context); - /** @var TView */ - return $result; + if ($presenter === null) { + /** @var (TView is null ? TOutput : TView) */ + return $output; + } + + /** @var (TView is null ? TOutput : TView) */ + return $presenter->present($output); } catch (\Throwable $e) { $this->logger->error('Usecase execution failed: ' . $e->getMessage(), [ ...$context, diff --git a/src/Shared/DDD/Usecase/Builder/BuilderStepPresenter.php b/src/Shared/DDD/Usecase/Builder/BuilderStepPresenter.php index 5334c02..fa5e139 100644 --- a/src/Shared/DDD/Usecase/Builder/BuilderStepPresenter.php +++ b/src/Shared/DDD/Usecase/Builder/BuilderStepPresenter.php @@ -4,10 +4,15 @@ declare(strict_types=1); namespace Shared\DDD\Usecase\Builder; +use Shared\DDD\Usecase\Output; use Shared\DDD\Usecase\Presenter; -/** @template TOutput of \Shared\DDD\Usecase\Output */ -interface BuilderStepPresenter +/** + * @template TOutput of Output + * + * @extends BuilderStepBuild + */ +interface BuilderStepPresenter extends BuilderStepBuild { /** * @template TView of object diff --git a/src/Shared/DDD/Usecase/Builder/UsecaseExecutionBuilder.php b/src/Shared/DDD/Usecase/Builder/UsecaseExecutionBuilder.php index 148dd59..9985dd6 100644 --- a/src/Shared/DDD/Usecase/Builder/UsecaseExecutionBuilder.php +++ b/src/Shared/DDD/Usecase/Builder/UsecaseExecutionBuilder.php @@ -83,8 +83,8 @@ final class UsecaseExecutionBuilder implements BuilderStepBuild, BuilderStepInpu #[\Override] public function run(): object { - if ($this->usecase === null || $this->input === null || $this->presenter === null) { - throw new LogicException('Usecase, input and presenter are all required.'); + if ($this->usecase === null || $this->input === null) { + throw new LogicException('Usecase and input are required.'); } /** @var TView */ diff --git a/src/Shared/DDD/Usecase/Builder/UsecaseExecutor.php b/src/Shared/DDD/Usecase/Builder/UsecaseExecutor.php index 1cea133..d06ff7c 100644 --- a/src/Shared/DDD/Usecase/Builder/UsecaseExecutor.php +++ b/src/Shared/DDD/Usecase/Builder/UsecaseExecutor.php @@ -17,8 +17,8 @@ interface UsecaseExecutor * * @param Usecase $usecase * @param TInput $input - * @param Presenter $presenter - * @return TView + * @param Presenter|null $presenter + * @return ($presenter is null ? TOutput : TView) */ - public function execute(Usecase $usecase, Input $input, Presenter $presenter): object; + public function execute(Usecase $usecase, Input $input, ?Presenter $presenter = null): object; } diff --git a/src/Shared/DDD/Usecase/Transactional.php b/src/Shared/DDD/Usecase/Transactional.php new file mode 100644 index 0000000..b0f8546 --- /dev/null +++ b/src/Shared/DDD/Usecase/Transactional.php @@ -0,0 +1,13 @@ +output; + } + }; + + /** @var DatabaseManager&Mockery\MockInterface $databaseMock */ + $databaseMock = Mockery::mock(DatabaseManager::class); + $databaseMock->shouldNotReceive('transaction'); + + /** @var LoggerInterface&Mockery\MockInterface $loggerMock */ + $loggerMock = Mockery::mock(LoggerInterface::class); + $loggerMock->shouldReceive('debug')->once(); + $loggerMock->shouldReceive('info')->once(); + + $executor = new LaravelExecutor($databaseMock, $loggerMock); + + // WHEN + $result = $executor->execute($usecaseMock, $inputMock); + + // THEN + expect($result)->toBe($outputMock); + }); + + it('executes transactional usecase inside a database transaction', function (): void { + // GIVEN + $inputMock = new class () implements Input + { + }; + $outputMock = new class () implements Output + { + }; + + $usecaseMock = new class ($outputMock) implements Transactional, Usecase + { + public function __construct(private readonly Output $output) + { + } + + public function execute(Input $input): Output + { + return $this->output; + } + }; + + /** @var DatabaseManager&Mockery\MockInterface $databaseMock */ + $databaseMock = Mockery::mock(DatabaseManager::class); + $databaseMock->shouldReceive('transaction') + ->once() + ->andReturnUsing(static fn (\Closure $callback) => $callback()); + + /** @var LoggerInterface&Mockery\MockInterface $loggerMock */ + $loggerMock = Mockery::mock(LoggerInterface::class); + $loggerMock->shouldReceive('debug')->once(); + $loggerMock->shouldReceive('info')->once(); + + $executor = new LaravelExecutor($databaseMock, $loggerMock); + + // WHEN + $result = $executor->execute($usecaseMock, $inputMock); + + // THEN + expect($result)->toBe($outputMock); + }); + + it('transforms output using presenter when presenter is provided', function (): void { + // GIVEN + $inputMock = new class () implements Input + { + }; + $outputMock = new class () implements Output + { + }; + $viewResult = new \stdClass(); + $viewResult->success = true; + + $usecaseMock = new class ($outputMock) implements Usecase + { + public function __construct(private readonly Output $output) + { + } + + public function execute(Input $input): Output + { + return $this->output; + } + }; + + $presenterMock = new class ($viewResult) implements Presenter + { + public function __construct(private readonly object $view) + { + } + + public function present(Output $output): object + { + return $this->view; + } + }; + + /** @var DatabaseManager&Mockery\MockInterface $databaseMock */ + $databaseMock = Mockery::mock(DatabaseManager::class); + $databaseMock->shouldNotReceive('transaction'); + + /** @var LoggerInterface&Mockery\MockInterface $loggerMock */ + $loggerMock = Mockery::mock(LoggerInterface::class); + $loggerMock->shouldReceive('debug')->once(); + $loggerMock->shouldReceive('info')->once(); + + $executor = new LaravelExecutor($databaseMock, $loggerMock); + + // WHEN + $result = $executor->execute($usecaseMock, $inputMock, $presenterMock); + + // THEN + expect($result)->toBe($viewResult); + }); + + it('logs error and rethrows exception when usecase execution fails', function (): void { + // GIVEN + $inputMock = new class () implements Input + { + }; + $exception = new \RuntimeException('Database connection lost'); + + $usecaseMock = new class ($exception) implements Transactional, Usecase + { + public function __construct(private readonly \RuntimeException $exception) + { + } + + public function execute(Input $input): Output + { + throw $this->exception; + } + }; + + /** @var DatabaseManager&Mockery\MockInterface $databaseMock */ + $databaseMock = Mockery::mock(DatabaseManager::class); + $databaseMock->shouldReceive('transaction') + ->once() + ->andReturnUsing(static fn (\Closure $callback) => $callback()); + + /** @var LoggerInterface&Mockery\MockInterface $loggerMock */ + $loggerMock = Mockery::mock(LoggerInterface::class); + $loggerMock->shouldReceive('debug')->once(); + $loggerMock->shouldReceive('error')->once(); + + $executor = new LaravelExecutor($databaseMock, $loggerMock); + + // WHEN & THEN + expect(fn () => $executor->execute($usecaseMock, $inputMock)) + ->toThrow(\RuntimeException::class, 'Database connection lost'); + }); + + it('includes user details in logging context when user details provider is present', function (): void { + // GIVEN + $inputMock = new class () implements Input + { + }; + $outputMock = new class () implements Output + { + }; + + $usecaseMock = new class ($outputMock) implements Usecase + { + public function __construct(private readonly Output $output) + { + } + + public function execute(Input $input): Output + { + return $this->output; + } + }; + + $userType = new class () implements UserType + { + public function type(): string + { + return 'api_client'; + } + + public function equals(mixed $other): bool + { + return $other instanceof self && $other->type() === $this->type(); + } + }; + + $userDetails = new class ($userType) implements UserDetails + { + public function __construct(private readonly UserType $userType) + { + } + + public function id(): string + { + return 'uuid-123'; + } + + public function name(): string + { + return 'Test Client'; + } + + public function type(): UserType + { + return $this->userType; + } + + public function identifier(): string + { + return 'client-key-456'; + } + }; + + /** @var UserDetailsProvider&Mockery\MockInterface $userProviderMock */ + $userProviderMock = Mockery::mock(UserDetailsProvider::class); + $userProviderMock->shouldReceive('currentUser')->andReturn($userDetails); + + /** @var DatabaseManager&Mockery\MockInterface $databaseMock */ + $databaseMock = Mockery::mock(DatabaseManager::class); + + /** @var LoggerInterface&Mockery\MockInterface $loggerMock */ + $loggerMock = Mockery::mock(LoggerInterface::class); + $loggerMock->shouldReceive('debug')->once()->with( + 'Starting usecase execution', + Mockery::on( + static fn (array $context): bool => isset($context['user']) + && $context['user']['id'] === 'uuid-123' + && $context['user']['name'] === 'Test Client' + && $context['user']['type'] === 'api_client' + && $context['user']['identifier'] === 'client-key-456' + && $context['transactional'] === false + ) + ); + $loggerMock->shouldReceive('info')->once(); + + $executor = new LaravelExecutor($databaseMock, $loggerMock, $userProviderMock); + + // WHEN + $result = $executor->execute($usecaseMock, $inputMock); + + // THEN + expect($result)->toBe($outputMock); + }); +}); diff --git a/tests/Unit/Shared/DDD/Usecase/Builder/UsecaseBuilderTest.php b/tests/Unit/Shared/DDD/Usecase/Builder/UsecaseBuilderTest.php index 9fcf848..66096c6 100644 --- a/tests/Unit/Shared/DDD/Usecase/Builder/UsecaseBuilderTest.php +++ b/tests/Unit/Shared/DDD/Usecase/Builder/UsecaseBuilderTest.php @@ -19,7 +19,7 @@ covers( ); describe('UsecaseBuilder Unit Tests', function (): void { - it('successfully builds and executes a use case using fluent syntax', function (): void { + it('successfully builds and executes a use case using fluent syntax with presenter', function (): void { // GIVEN $inputMock = new class () implements Input { @@ -60,11 +60,11 @@ describe('UsecaseBuilder Unit Tests', function (): void { { } - public function execute(Usecase $usecase, Input $input, Presenter $presenter): object + public function execute(Usecase $usecase, Input $input, ?Presenter $presenter = null): object { $output = $usecase->execute($input); - return $presenter->present($output); + return $presenter?->present($output) ?? $output; } }; @@ -79,11 +79,52 @@ describe('UsecaseBuilder Unit Tests', function (): void { expect($result)->toBe($viewResult); }); + it('successfully builds and executes a use case without presenter returning Output directly', function (): void { + // GIVEN + $inputMock = new class () implements Input + { + }; + $outputMock = new class () implements Output + { + }; + + $usecaseMock = new class ($outputMock) implements Usecase + { + public function __construct(private readonly Output $output) + { + } + + public function execute(Input $input): Output + { + return $this->output; + } + }; + + $executorMock = new class () implements UsecaseExecutor + { + public function execute(Usecase $usecase, Input $input, ?Presenter $presenter = null): object + { + $output = $usecase->execute($input); + + return $presenter?->present($output) ?? $output; + } + }; + + // WHEN + $result = UsecaseBuilder::using($executorMock) + ->do($usecaseMock) + ->from($inputMock) + ->run(); + + // THEN + expect($result)->toBe($outputMock); + }); + it('throws LogicException when setting input before usecase', function (): void { // GIVEN $executorMock = new class () implements UsecaseExecutor { - public function execute(Usecase $usecase, Input $input, Presenter $presenter): object + public function execute(Usecase $usecase, Input $input, ?Presenter $presenter = null): object { return new \stdClass(); } @@ -104,7 +145,7 @@ describe('UsecaseBuilder Unit Tests', function (): void { // GIVEN $executorMock = new class () implements UsecaseExecutor { - public function execute(Usecase $usecase, Input $input, Presenter $presenter): object + public function execute(Usecase $usecase, Input $input, ?Presenter $presenter = null): object { return new \stdClass(); } @@ -129,7 +170,7 @@ describe('UsecaseBuilder Unit Tests', function (): void { // GIVEN $executorMock = new class () implements UsecaseExecutor { - public function execute(Usecase $usecase, Input $input, Presenter $presenter): object + public function execute(Usecase $usecase, Input $input, ?Presenter $presenter = null): object { return new \stdClass(); } @@ -139,6 +180,6 @@ describe('UsecaseBuilder Unit Tests', function (): void { $builder = new UsecaseExecutionBuilder($executorMock); expect(fn () => $builder->run()) - ->toThrow(LogicException::class, 'Usecase, input and presenter are all required.'); + ->toThrow(LogicException::class, 'Usecase and input are required.'); }); });