feat(console): add ListClients command and related presenters

- Implemented ListClientsCommand to list API clients with pagination.
- Created ClientRow, ListClientsPresenter, and ListClientsView for structured output.
- Added tests for ListClientsCommand to ensure correct functionality.
This commit is contained in:
Jérémy Munsch 2026-08-18 12:50:55 +02:00
parent af416b1b7c
commit 2ac8578fe0
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
6 changed files with 260 additions and 0 deletions

View file

@ -12,6 +12,9 @@ $app = Application::configure(Env::get('APP_BASE_PATH', dirname(__DIR__)))
commands: __DIR__ . '/../routes/console.php',
health: '/up',
)
->withCommands([
__DIR__ . '/../src/Presentation/Console/Commands',
])
->withMiddleware(require __DIR__ . '/middlewares.php')
->withExceptions(require __DIR__ . '/exceptions.php')
->create();

View file

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Presentation\Console\Commands;
use Application\Security\Usecases\FindAllClients\FindAllClients;
use Application\Security\Usecases\FindAllClients\Input;
use Illuminate\Console\Command;
use Presentation\Console\Presenters\Clients\ClientRow;
use Presentation\Console\Presenters\Clients\ListClientsPresenter;
use Presentation\Console\Presenters\Clients\ListClientsView;
use Shared\DDD\Usecase\Builder\UsecaseBuilder;
use Shared\DDD\Usecase\Builder\UsecaseExecutor;
use Shared\Pagination;
final class ListClientsCommand extends Command
{
/**
* @var string
*/
protected $signature = 'client:list
{--page=1 : The page number}
{--per-page=25 : The number of clients per page}';
/**
* @var string
*/
protected $description = 'List all registered API clients';
public function handle(
FindAllClients $usecase,
UsecaseExecutor $executor,
ListClientsPresenter $presenter,
): int {
$pagination = $this->getPaginationFromOptions();
$input = new Input($pagination);
$view = UsecaseBuilder::using($executor)
->do($usecase)
->from($input)
->with($presenter)
->run();
return $this->renderView($view);
}
private function renderView(ListClientsView $view): int
{
if ($view->isEmpty()) {
$this->info('No clients found.');
return self::SUCCESS;
}
$tableRows = array_map(
static fn (ClientRow $row): array => array_values(get_object_vars($row)),
$view->rows
);
$this->table($view->headers, $tableRows);
$this->info($view->paginationSummary());
return self::SUCCESS;
}
private function getPaginationFromOptions(): Pagination
{
return Pagination::ofPage(
max(1, (int) $this->option('page')),
clamp(1, (int) $this->option('per-page'), Pagination::MAX_PAGE_SIZE)
);
}
}

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Presentation\Console\Presenters\Clients;
final readonly class ClientRow
{
public function __construct(
public string $id,
public string $name,
public string $identifier,
public string $status,
public string $lastUpdatedAt,
) {
}
}

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Presentation\Console\Presenters\Clients;
use Application\Security\Usecases\FindAllClients\Output;
use Application\Security\Usecases\FindAllClients\PaginatedClient;
use Shared\DDD\Usecase\Output as UsecaseOutput;
use Shared\DDD\Usecase\Presenter;
use Webmozart\Assert\Assert;
/** @implements Presenter<Output, ListClientsView> */
final readonly class ListClientsPresenter implements Presenter
{
#[\Override]
public function present(UsecaseOutput $output): object
{
Assert::isInstanceOf($output, Output::class);
/** @var list<ClientRow> $rows */
$rows = array_values(array_map(
static fn (PaginatedClient $client): ClientRow => new ClientRow(
id: $client->id->value,
name: $client->name->value,
identifier: $client->identifier->value,
status: $client->isActive ? 'Active' : 'Inactive',
lastUpdatedAt: $client->updatedAt->format('Y-m-d H:i:s'),
),
$output->clients
));
$totalPages = $output->perPage > 0 ? (int) ceil($output->total / $output->perPage) : 0;
return new ListClientsView(
headers: ['ID', 'Name', 'Identifier', 'Status', 'Last Updated At'],
rows: $rows,
total: $output->total,
page: $output->page,
perPage: $output->perPage,
totalPages: $totalPages,
);
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Presentation\Console\Presenters\Clients;
final readonly class ListClientsView
{
/**
* @param list<string> $headers
* @param list<ClientRow> $rows
*/
public function __construct(
public array $headers,
public array $rows,
public int $total,
public int $page,
public int $perPage,
public int $totalPages,
) {
}
public function isEmpty(): bool
{
return $this->total === 0;
}
public function paginationSummary(): string
{
return "Showing page {$this->page}/{$this->totalPages} (Total: {$this->total} client(s))";
}
}

View file

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Console\Commands;
use Infra\Database\Models\ApiClient;
use Presentation\Console\Commands\ListClientsCommand;
use Ramsey\Uuid\Uuid;
covers(ListClientsCommand::class);
describe('ListClientsCommand', function (): void {
it('outputs message when no clients exist', function (): void {
// GIVEN
ApiClient::query()->delete();
// WHEN & THEN
$this->artisan('client:list')
->expectsOutput('No clients found.')
->assertSuccessful();
});
it('lists clients in a table when clients exist', function (): void {
// GIVEN
ApiClient::query()->delete();
$client1 = ApiClient::query()->create([
'id' => (string) Uuid::uuid7(),
'name' => 'Gateway Alpha',
'identifier' => 'arcadia_id_' . str_repeat('a', 32),
'secret' => 'arcadia_sec_' . str_repeat('b', 86),
'is_active' => true,
]);
$client2 = ApiClient::query()->create([
'id' => (string) Uuid::uuid7(),
'name' => 'Gateway Beta',
'identifier' => 'arcadia_id_' . str_repeat('c', 32),
'secret' => 'arcadia_sec_' . str_repeat('d', 86),
'is_active' => false,
]);
// WHEN & THEN
$this->artisan('client:list')
->expectsTable(
['ID', 'Name', 'Identifier', 'Status', 'Created At', 'Updated At'],
[
[
(string) $client1->id,
(string) $client1->name->value,
(string) $client1->identifier->value,
'Active',
$client1->created_at->format('Y-m-d H:i:s'),
$client1->updated_at->format('Y-m-d H:i:s'),
],
[
(string) $client2->id,
(string) $client2->name->value,
(string) $client2->identifier->value,
'Inactive',
$client2->created_at->format('Y-m-d H:i:s'),
$client2->updated_at->format('Y-m-d H:i:s'),
],
]
)
->expectsOutput('Showing page 1/1 (Total: 2 client(s))')
->assertSuccessful();
});
it('respects pagination options', function (): void {
// GIVEN
ApiClient::query()->delete();
for ($i = 1; $i <= 3; $i++) {
ApiClient::query()->create([
'id' => (string) Uuid::uuid7(),
'name' => "Client {$i}",
'identifier' => 'arcadia_id_' . str_repeat((string) $i, 32),
'secret' => 'arcadia_sec_' . str_repeat((string) $i, 86),
'is_active' => true,
]);
}
// WHEN & THEN
$this->artisan('client:list', ['--page' => '2', '--per-page' => '2'])
->expectsOutput('Showing page 2/2 (Total: 3 client(s))')
->assertSuccessful();
});
});