feat(usecase): add Transactional interface and update use cases
- Implemented Transactional interface for use cases requiring database transactions. - Updated CreateClient, RegenerateClientSecret, and SetClientStatus to implement Transactional. - Modified LaravelExecutor to handle transactional use cases appropriately. - Enhanced tests for LaravelExecutor to cover transactional behavior.
This commit is contained in:
parent
aca627ff78
commit
9c2c109d51
10 changed files with 384 additions and 30 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<TInput, TOutput> $usecase
|
||||
* @param TInput $input
|
||||
* @param Presenter<TOutput, TView> $presenter
|
||||
* @return TView
|
||||
* @param Presenter<TOutput, TView>|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,
|
||||
|
|
|
|||
|
|
@ -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<TOutput>
|
||||
*/
|
||||
interface BuilderStepPresenter extends BuilderStepBuild
|
||||
{
|
||||
/**
|
||||
* @template TView of object
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ interface UsecaseExecutor
|
|||
*
|
||||
* @param Usecase<TInput, TOutput> $usecase
|
||||
* @param TInput $input
|
||||
* @param Presenter<TOutput, TView> $presenter
|
||||
* @return TView
|
||||
* @param Presenter<TOutput, TView>|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;
|
||||
}
|
||||
|
|
|
|||
13
src/Shared/DDD/Usecase/Transactional.php
Normal file
13
src/Shared/DDD/Usecase/Transactional.php
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shared\DDD\Usecase;
|
||||
|
||||
/**
|
||||
* Marker interface for use cases that perform database mutations
|
||||
* requiring an atomic database transaction.
|
||||
*/
|
||||
interface Transactional
|
||||
{
|
||||
}
|
||||
284
tests/Integration/Infra/Usecase/LaravelExecutorTest.php
Normal file
284
tests/Integration/Infra/Usecase/LaravelExecutorTest.php
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration\Infra\Usecase;
|
||||
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Infra\Usecase\LaravelExecutor;
|
||||
use Mockery;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shared\DDD\Security\UserDetails;
|
||||
use Shared\DDD\Security\UserDetailsProvider;
|
||||
use Shared\DDD\Security\UserType;
|
||||
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;
|
||||
|
||||
covers(LaravelExecutor::class);
|
||||
|
||||
describe('LaravelExecutor Integration Tests', function (): void {
|
||||
it('executes non-transactional usecase directly without opening a database transaction', 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;
|
||||
}
|
||||
};
|
||||
|
||||
/** @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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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.');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue