feat(console): add CreateClientCommand and related classes
Implement an interactive command to create a new API client, including input validation and confirmation steps. Add presenter and view classes to handle the output of the client creation process.
This commit is contained in:
parent
43758922ad
commit
0a5603330a
4 changed files with 262 additions and 0 deletions
125
src/Presentation/Console/Commands/CreateClientCommand.php
Normal file
125
src/Presentation/Console/Commands/CreateClientCommand.php
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Presentation\Console\Commands;
|
||||
|
||||
use Application\Security\Models\ValueObjects\ClientName;
|
||||
use Application\Security\Usecases\CreateClient\CreateClient;
|
||||
use Application\Security\Usecases\CreateClient\Input;
|
||||
use Illuminate\Console\Command;
|
||||
use Presentation\Console\Presenters\Clients\CreateClientPresenter;
|
||||
use Presentation\Console\Presenters\Clients\CreateClientView;
|
||||
use Shared\DDD\Usecase\Builder\UsecaseBuilder;
|
||||
use Shared\DDD\Usecase\Builder\UsecaseExecutor;
|
||||
|
||||
final class CreateClientCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'client:create';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Create a new API client (interactive wizard)';
|
||||
|
||||
public function handle(
|
||||
CreateClient $usecase,
|
||||
UsecaseExecutor $executor,
|
||||
): int {
|
||||
$clientName = $this->askForClientName();
|
||||
|
||||
if ($clientName === null) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $this->confirmCreation($clientName)) {
|
||||
$this->warn('Client creation cancelled.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$input = new Input($clientName);
|
||||
|
||||
/** @var CreateClientView $view */
|
||||
$view = UsecaseBuilder::using($executor)
|
||||
->do($usecase)
|
||||
->from($input)
|
||||
->with(new CreateClientPresenter())
|
||||
->run();
|
||||
|
||||
$this->displayCredentials($view);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function askForClientName(): ?ClientName
|
||||
{
|
||||
$maxAttempts = 3;
|
||||
|
||||
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
|
||||
/** @var string|null $nameInput */
|
||||
$nameInput = $this->ask('Client name (e.g. "Ones Branding website")');
|
||||
$nameInput = $nameInput !== null ? trim($nameInput) : '';
|
||||
|
||||
if ($nameInput === '') {
|
||||
$this->error('Client name cannot be empty.');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
return new ClientName($nameInput);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$this->error($e->getMessage());
|
||||
|
||||
if ($attempt < $maxAttempts) {
|
||||
$this->warn(sprintf('You have %d attempt(s) remaining.', $maxAttempts - $attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->error('Maximum number of attempts reached. Aborting.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function confirmCreation(ClientName $name): bool
|
||||
{
|
||||
$this->newLine();
|
||||
$this->info('Summary:');
|
||||
$this->table(
|
||||
['Field', 'Value'],
|
||||
[
|
||||
['Name', $name->value],
|
||||
['Identifier', '<auto-generated>'],
|
||||
['Secret', '<auto-generated>'],
|
||||
['Status', 'Active'],
|
||||
],
|
||||
);
|
||||
|
||||
return $this->confirm('Create this API client?', false);
|
||||
}
|
||||
|
||||
private function displayCredentials(CreateClientView $view): void
|
||||
{
|
||||
$this->newLine();
|
||||
$this->info($view->message);
|
||||
$this->newLine();
|
||||
|
||||
$this->table(
|
||||
['Field', 'Value'],
|
||||
[
|
||||
['ID', $view->id],
|
||||
['Name', $view->name],
|
||||
['Identifier', $view->identifier],
|
||||
['Secret', $view->secret],
|
||||
],
|
||||
);
|
||||
|
||||
$this->newLine();
|
||||
$this->warn('WARNING: Store the secret securely, it cannot be retrieved later.');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Presentation\Console\Presenters\Clients;
|
||||
|
||||
use Application\Security\Usecases\CreateClient\Output;
|
||||
use Shared\DDD\Usecase\Output as UsecaseOutput;
|
||||
use Shared\DDD\Usecase\Presenter;
|
||||
use Webmozart\Assert\Assert;
|
||||
|
||||
/** @implements Presenter<Output, CreateClientView> */
|
||||
final readonly class CreateClientPresenter implements Presenter
|
||||
{
|
||||
#[\Override]
|
||||
public function present(UsecaseOutput $output): object
|
||||
{
|
||||
Assert::isInstanceOf($output, Output::class);
|
||||
|
||||
return new CreateClientView(
|
||||
id: $output->clientId->value,
|
||||
name: $output->clientName->value,
|
||||
identifier: $output->clientIdentifier->value,
|
||||
secret: $output->clientSecret->value,
|
||||
message: "API client '{$output->clientName->value}' created successfully.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Presentation\Console\Presenters\Clients;
|
||||
|
||||
final readonly class CreateClientView
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $name,
|
||||
public string $identifier,
|
||||
public string $secret,
|
||||
public string $message,
|
||||
) {
|
||||
}
|
||||
}
|
||||
92
tests/Feature/Console/Commands/CreateClientCommandTest.php
Normal file
92
tests/Feature/Console/Commands/CreateClientCommandTest.php
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Console\Commands;
|
||||
|
||||
use Infra\Database\Models\ApiClient;
|
||||
use Presentation\Console\Commands\CreateClientCommand;
|
||||
|
||||
covers(CreateClientCommand::class);
|
||||
|
||||
describe('CreateClientCommand', function (): void {
|
||||
it('creates a client through the wizard', function (): void {
|
||||
// GIVEN
|
||||
/** @var \Tests\TestCase $this */
|
||||
$clientName = 'Production Gateway';
|
||||
|
||||
// WHEN
|
||||
$this->artisan('client:create')
|
||||
|
||||
// THEN
|
||||
->expectsQuestion('Client name (e.g. "Production Gateway")', $clientName)
|
||||
->expectsConfirmation('Create this API client?', 'yes')
|
||||
->expectsOutputToContain("API client '{$clientName}' created successfully.")
|
||||
->expectsOutputToContain('Store the secret securely')
|
||||
->assertSuccessful();
|
||||
|
||||
$client = ApiClient::query()->where('name', $clientName)->first();
|
||||
expect($client)->not->toBeNull()
|
||||
->and($client->is_active)->toBeTrue()
|
||||
->and($client->name->value)->toBe($clientName)
|
||||
->and((string) $client->identifier->value)->toStartWith('arcadia_id_')
|
||||
->and((string) $client->secret->value)->toStartWith('arcadia_sec_');
|
||||
});
|
||||
|
||||
it('aborts when user cancels confirmation', function (): void {
|
||||
// GIVEN
|
||||
/** @var \Tests\TestCase $this */
|
||||
$clientName = 'Cancelled Gateway';
|
||||
|
||||
// WHEN
|
||||
$this->artisan('client:create')
|
||||
|
||||
// THEN
|
||||
->expectsQuestion('Client name (e.g. "Production Gateway")', $clientName)
|
||||
->expectsConfirmation('Create this API client?', 'no')
|
||||
->expectsOutput('Client creation cancelled.')
|
||||
->assertSuccessful();
|
||||
|
||||
$client = ApiClient::query()->where('name', $clientName)->first();
|
||||
expect($client)->toBeNull();
|
||||
});
|
||||
|
||||
it('retries when an empty name is provided', function (): void {
|
||||
// GIVEN
|
||||
/** @var \Tests\TestCase $this */
|
||||
$this->artisan('client:create')
|
||||
->expectsQuestion('Client name (e.g. "Production Gateway")', '')
|
||||
->expectsOutput('Client name cannot be empty.')
|
||||
->expectsQuestion('Client name (e.g. "Production Gateway")', 'Valid Gateway')
|
||||
|
||||
// WHEN
|
||||
->expectsConfirmation('Create this API client?', 'yes')
|
||||
->expectsOutputToContain("API client 'Valid Gateway' created successfully.")
|
||||
|
||||
// THEN
|
||||
->assertSuccessful();
|
||||
|
||||
$client = ApiClient::query()
|
||||
->where('name', 'Valid Gateway')
|
||||
->first();
|
||||
expect($client)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('fails after maximum retries with invalid names', function (): void {
|
||||
// GIVEN
|
||||
/** @var \Tests\TestCase $this */
|
||||
$this->artisan('client:create')
|
||||
|
||||
// WHEN
|
||||
->expectsQuestion('Client name (e.g. "Production Gateway")', '')
|
||||
->expectsOutput('Client name cannot be empty.')
|
||||
->expectsQuestion('Client name (e.g. "Production Gateway")', '')
|
||||
->expectsOutput('Client name cannot be empty.')
|
||||
->expectsQuestion('Client name (e.g. "Production Gateway")', '')
|
||||
->expectsOutput('Client name cannot be empty.')
|
||||
->expectsOutput('Maximum number of attempts reached. Aborting.')
|
||||
|
||||
// THEN
|
||||
->assertFailed();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue