From f330b395462e56c5a098c1f779c470ed8c06c651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sat, 4 Oct 2025 17:26:02 +0300 Subject: [PATCH 1/3] docs: update cursor rules for server management --- .cursor/rules/01-architecture.mdc | 2 ++ .cursor/rules/02-tests.mdc | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.cursor/rules/01-architecture.mdc b/.cursor/rules/01-architecture.mdc index 7f6632c5..8d7c8981 100644 --- a/.cursor/rules/01-architecture.mdc +++ b/.cursor/rules/01-architecture.mdc @@ -141,3 +141,5 @@ vendor/bin/pint $CHANGED_PHP_FILES # Fix code style (changed files only) # Static analysis excluding tests (never do static analysis against tests) vendor/bin/phpstan analyze $CHANGED_PHP_FILES_EXCEPT_TESTS ``` + +**Important: ** Don't run PHPStan on test files; tests are excluded from static analysis. diff --git a/.cursor/rules/02-tests.mdc b/.cursor/rules/02-tests.mdc index 62bb1a01..b563c239 100644 --- a/.cursor/rules/02-tests.mdc +++ b/.cursor/rules/02-tests.mdc @@ -123,5 +123,7 @@ $mock->shouldReceive('method')->with('param')->andReturn('result'); ### Static Analysis +**Running PHPStan applies to PRODUCTION code, not tests.** + - Ignore PHPStan issues in tests - focus on test functionality over compliance - Avoid excessive phpdoc just to appease types From 14293b975ec2f72d41412f5b1e2d5427945a03e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sat, 4 Oct 2025 17:26:03 +0300 Subject: [PATCH 2/3] feat(server): add add/delete/list commands and supporting traits --- app/Console/Server/ServerAddCommand.php | 246 +++++++++++++++++++++ app/Console/Server/ServerDeleteCommand.php | 133 +++++++++++ app/Console/Server/ServerListCommand.php | 56 +++++ app/Contracts/BaseCommand.php | 2 + app/Services/SSHService.php | 7 +- app/SymfonyApp.php | 6 + app/Traits/ServerHelpersTrait.php | 35 +++ app/Traits/ServerValidationTrait.php | 44 ++++ 8 files changed, 523 insertions(+), 6 deletions(-) create mode 100644 app/Console/Server/ServerAddCommand.php create mode 100644 app/Console/Server/ServerDeleteCommand.php create mode 100644 app/Console/Server/ServerListCommand.php create mode 100644 app/Traits/ServerHelpersTrait.php create mode 100644 app/Traits/ServerValidationTrait.php diff --git a/app/Console/Server/ServerAddCommand.php b/app/Console/Server/ServerAddCommand.php new file mode 100644 index 00000000..44009700 --- /dev/null +++ b/app/Console/Server/ServerAddCommand.php @@ -0,0 +1,246 @@ +addOption('name', null, InputOption::VALUE_REQUIRED, 'Server name') + ->addOption('host', null, InputOption::VALUE_REQUIRED, 'Host/IP address') + ->addOption('port', null, InputOption::VALUE_REQUIRED, 'SSH port (default: 22)') + ->addOption('username', null, InputOption::VALUE_REQUIRED, 'SSH username (default: root)') + ->addOption('private-key-path', null, InputOption::VALUE_REQUIRED, 'SSH private key path') + ->addOption('skip', null, InputOption::VALUE_NONE, 'Skip SSH connection check') + ->addOption('yes', 'y', InputOption::VALUE_NONE, 'Skip confirmation prompt'); + } + + // + // Execution + // ------------------------------------------------------------------------------- + + protected function execute(InputInterface $input, OutputInterface $output): int + { + parent::execute($input, $output); + + $this->hr(); + + $this->h1('Add New Server'); + + // + // Gather server details + + /** @var string $name */ + $name = $this->getOptionOrPrompt( + 'name', + fn (): string => $this->promptText( + label: 'Server name:', + placeholder: 'web1', + required: true + ) + ); + + /** @var string $host */ + $host = $this->getOptionOrPrompt( + 'host', + fn (): string => $this->promptText( + label: 'Host/IP address:', + placeholder: '192.168.1.100', + required: true + ) + ); + + $this->validateHost($host); + + /** @var string $portString */ + $portString = $this->getOptionOrPrompt( + 'port', + fn (): string => $this->promptText( + label: 'SSH port:', + default: '22', + required: true + ) + ); + + $port = (int) $portString; + $this->validatePort($port); + + /** @var string $username */ + $username = $this->getOptionOrPrompt( + 'username', + fn (): string => $this->promptText( + label: 'SSH username:', + default: 'root', + required: true + ) + ); + + /** @var string $privateKeyPathRaw */ + $privateKeyPathRaw = $this->getOptionOrPrompt( + 'private-key-path', + fn (): string => $this->promptText( + label: 'SSH private key path (leave empty for default ~/.ssh/id_ed25519 or ~/.ssh/id_rsa):', + default: '', + required: false + ) + ); + + /** @var ?string $privateKeyPath */ + $privateKeyPath = $privateKeyPathRaw !== '' ? $privateKeyPathRaw : null; + + // + // Create DTO and display server info + + $server = new ServerDTO( + name: $name, + host: $host, + port: $port, + username: $username, + privateKeyPath: $privateKeyPath + ); + + $this->hr(); + + $this->displayServerInfo($server); + + // + // Verify connectivity + + /** @var bool $skipCheck */ + $skipCheck = $this->getOptionOrPrompt( + 'skip', + fn (): bool => !$this->promptConfirm( + label: 'Test SSH connection before saving?', + default: true + ) + ); + + if ($skipCheck) { + $this->warning('Skipping SSH connection check'); + $this->writeln(''); + } else { + if (!$this->testConnection($server)) { + return Command::FAILURE; + } + } + + // + // Confirm creation + + /** @var bool $confirmed */ + $confirmed = $this->getOptionOrPrompt( + 'yes', + fn (): bool => $this->promptConfirm( + label: 'Save this server to inventory?', + default: true + ) + ); + + if (!$confirmed) { + $this->warning('Cancelled adding server'); + $this->writeln(''); + + return Command::SUCCESS; + } + + // + // Save to repository + + try { + $this->servers->create($server); + } catch (\RuntimeException $e) { + $this->error('Failed to add server: ' . $e->getMessage()); + + return Command::FAILURE; + } + + $this->success('Server added successfully'); + $this->writeln(''); + + // + // Show command hint + + $this->showCommandHint('server:add', [ + 'name' => $name, + 'host' => $host, + 'port' => $port, + 'username' => $username, + 'private-key-path' => $privateKeyPath, + 'skip' => $skipCheck, + 'yes' => $confirmed, + ]); + + return Command::SUCCESS; + } + + // + // Private Helpers + // ------------------------------------------------------------------------------- + + /** + * Test SSH connection to server with detailed output. + */ + private function testConnection(ServerDTO $server): bool + { + try { + $this->promptSpin( + callback: fn () => $this->ssh->assertCanConnect( + $server->host, + $server->port, + $server->username, + $server->privateKeyPath + ), + message: 'Connecting to server...' + ); + + $this->success('SSH connection successful'); + + return true; + } catch (\RuntimeException $e) { + $this->error($e->getMessage()); + + $this->writeln([ + '', + ' Common issues:', + '', + ' • Check that the server is accessible from your network', + ' • Verify SSH is running on the server (port '.$server->port.')', + ' • Ensure your SSH key has correct permissions (chmod 600)', + ' • Confirm username "'.$server->username.'" exists on the server', + '', + ' Tip: Use --skip to add server without testing connection.', + '', + ]); + + return false; + } + } +} diff --git a/app/Console/Server/ServerDeleteCommand.php b/app/Console/Server/ServerDeleteCommand.php new file mode 100644 index 00000000..f82fa929 --- /dev/null +++ b/app/Console/Server/ServerDeleteCommand.php @@ -0,0 +1,133 @@ +addOption('name', null, InputOption::VALUE_REQUIRED, 'Server name') + ->addOption('yes', 'y', InputOption::VALUE_NONE, 'Skip confirmation prompt'); + } + + // + // Execution + // ------------------------------------------------------------------------------- + + protected function execute(InputInterface $input, OutputInterface $output): int + { + parent::execute($input, $output); + + $this->hr(); + + // + // Get all servers + + $allServers = $this->servers->all(); + if (count($allServers) === 0) { + $this->warning('No servers found in inventory'); + $this->writeln([ + '', + 'Use server:add to add a server', + '', + ]); + + return Command::SUCCESS; + } + + // Extract server names from DTOs for promptSelect + $serverNames = array_map(fn (ServerDTO $server) => $server->name, $allServers); + + // + // Select server to delete + + $this->h1('Delete Server'); + + $name = (string) $this->getOptionOrPrompt( + 'name', + fn () => $this->promptSelect( + label: 'Select server:', + options: $serverNames, + ) + ); + + // + // Find server and display info + + $server = null; + foreach ($allServers as $s) { + if ($s->name === $name) { + $server = $s; + break; + } + } + + if ($server === null) { + $this->error("Server '{$name}' not found in inventory"); + return Command::FAILURE; + } + + $this->displayServerInfo($server); + + // + // Confirm deletion + + /** @var bool $confirmed */ + $confirmed = $this->getOptionOrPrompt( + 'yes', + fn (): bool => $this->promptConfirm( + label: 'Are you sure you want to delete this server?', + default: true + ) + ); + + if (!$confirmed) { + $this->warning('Cancelled deleting server'); + $this->writeln(''); + + return Command::SUCCESS; + } + + // + // Delete server + + $this->servers->delete($name); + + $this->success("Server '{$name}' deleted successfully"); + $this->writeln(''); + + // + // Show command hint + + $this->showCommandHint('server:delete', [ + 'name' => $name, + 'yes' => $confirmed, + ]); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Server/ServerListCommand.php b/app/Console/Server/ServerListCommand.php new file mode 100644 index 00000000..d9ab5933 --- /dev/null +++ b/app/Console/Server/ServerListCommand.php @@ -0,0 +1,56 @@ +hr(); + + // + // Get all servers + + $allServers = $this->servers->all(); + if (count($allServers) === 0) { + $this->warning('No servers found in inventory'); + $this->writeln([ + '', + 'Use server:add to add a server', + '', + ]); + + return Command::SUCCESS; + } + + $this->h1('All Servers'); + + foreach ($allServers as $server) { + $this->displayServerInfo($server); + } + + return Command::SUCCESS; + } + +} diff --git a/app/Contracts/BaseCommand.php b/app/Contracts/BaseCommand.php index 2bc705cf..972c161a 100644 --- a/app/Contracts/BaseCommand.php +++ b/app/Contracts/BaseCommand.php @@ -8,6 +8,7 @@ use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository; use Bigpixelrocket\DeployerPHP\Services\EnvService; use Bigpixelrocket\DeployerPHP\Services\InventoryService; +use Bigpixelrocket\DeployerPHP\Services\SSHService; use Bigpixelrocket\DeployerPHP\Traits\ConsoleInputTrait; use Bigpixelrocket\DeployerPHP\Traits\ConsoleOutputTrait; use Symfony\Component\Console\Command\Command; @@ -36,6 +37,7 @@ public function __construct( protected readonly EnvService $env, protected readonly InventoryService $inventory, protected readonly ServerRepository $servers, + protected readonly SSHService $ssh, ) { parent::__construct(); } diff --git a/app/Services/SSHService.php b/app/Services/SSHService.php index c74ae61d..1bcba927 100644 --- a/app/Services/SSHService.php +++ b/app/Services/SSHService.php @@ -185,14 +185,9 @@ private function createConnection(string $host, int $port, string $username, ?st try { $ssh = new SSH2($host, $port); - } catch (\Throwable $e) { - throw new \RuntimeException("Error initiating SSH connection to {$host}:{$port}: " . $e->getMessage()); - } - - try { $loggedIn = $ssh->login($username, $key); } catch (\Throwable $e) { - throw new \RuntimeException("Error authenticating SSH for {$username}@{$host}: " . $e->getMessage()); + throw new \RuntimeException($e->getMessage()); } if ($loggedIn !== true) { diff --git a/app/SymfonyApp.php b/app/SymfonyApp.php index 1e09988d..61d0d148 100644 --- a/app/SymfonyApp.php +++ b/app/SymfonyApp.php @@ -5,6 +5,9 @@ namespace Bigpixelrocket\DeployerPHP; use Bigpixelrocket\DeployerPHP\Console\HelloCommand; +use Bigpixelrocket\DeployerPHP\Console\Server\ServerAddCommand; +use Bigpixelrocket\DeployerPHP\Console\Server\ServerDeleteCommand; +use Bigpixelrocket\DeployerPHP\Console\Server\ServerListCommand; use Bigpixelrocket\DeployerPHP\Services\VersionService; use Symfony\Component\Console\Application as SymfonyApplication; use Symfony\Component\Console\Command\Command; @@ -114,6 +117,9 @@ private function registerCommands(): void { $commands = [ HelloCommand::class, + ServerAddCommand::class, + ServerDeleteCommand::class, + ServerListCommand::class, ]; foreach ($commands as $command) { diff --git a/app/Traits/ServerHelpersTrait.php b/app/Traits/ServerHelpersTrait.php new file mode 100644 index 00000000..11cd7488 --- /dev/null +++ b/app/Traits/ServerHelpersTrait.php @@ -0,0 +1,35 @@ +writeln([ + " Name: {$server->name}", + " Host: {$server->host}", + " Port: {$server->port}", + " User: {$server->username}", + " Key: ".($server->privateKeyPath ?? 'default (~/.ssh/id_ed25519 or ~/.ssh/id_rsa)')."", + " " + ]); + } + +} diff --git a/app/Traits/ServerValidationTrait.php b/app/Traits/ServerValidationTrait.php new file mode 100644 index 00000000..27236148 --- /dev/null +++ b/app/Traits/ServerValidationTrait.php @@ -0,0 +1,44 @@ + 65535) { + throw new \InvalidArgumentException( + "Invalid port {$port}. Port must be between 1 and 65535.\n". + 'Common SSH ports: 22 (default), 2222, 22000' + ); + } + } +} From de668075480539d227595a8af5127457cc276474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sat, 4 Oct 2025 17:26:04 +0300 Subject: [PATCH 3/3] test(server): add unit and integration tests for server commands --- tests/Fixtures/TestConsoleCommand.php | 9 +- .../Console/Server/ServerAddCommandTest.php | 352 ++++++++++++++++++ .../Server/ServerDeleteCommandTest.php | 229 ++++++++++++ .../Console/Server/ServerListCommandTest.php | 205 ++++++++++ tests/TestHelpers.php | 88 ++++- tests/Unit/Contracts/BaseCommandTest.php | 10 +- tests/Unit/Traits/ServerHelpersTraitTest.php | 84 +++++ .../Unit/Traits/ServerValidationTraitTest.php | 154 ++++++++ 8 files changed, 1125 insertions(+), 6 deletions(-) create mode 100644 tests/Integration/Console/Server/ServerAddCommandTest.php create mode 100644 tests/Integration/Console/Server/ServerDeleteCommandTest.php create mode 100644 tests/Integration/Console/Server/ServerListCommandTest.php create mode 100644 tests/Unit/Traits/ServerHelpersTraitTest.php create mode 100644 tests/Unit/Traits/ServerValidationTraitTest.php diff --git a/tests/Fixtures/TestConsoleCommand.php b/tests/Fixtures/TestConsoleCommand.php index 983162cc..dc8d441c 100644 --- a/tests/Fixtures/TestConsoleCommand.php +++ b/tests/Fixtures/TestConsoleCommand.php @@ -9,6 +9,8 @@ use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository; use Bigpixelrocket\DeployerPHP\Services\EnvService; use Bigpixelrocket\DeployerPHP\Services\InventoryService; +use Bigpixelrocket\DeployerPHP\Services\SSHService; +use Bigpixelrocket\DeployerPHP\Traits\ServerHelpersTrait; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -17,10 +19,11 @@ /** * Test fixture for BaseCommand trait testing. * - * Supports testing both ConsoleInputTrait and ConsoleOutputTrait methods. + * Supports testing both ConsoleInputTrait, ConsoleOutputTrait, and ServerHelpersTrait methods. */ class TestConsoleCommand extends BaseCommand { + use ServerHelpersTrait; private string $methodToTest = ''; private array $testArgs = []; @@ -30,8 +33,9 @@ public function __construct( EnvService $env, InventoryService $inventory, ServerRepository $servers, + SSHService $ssh, ) { - parent::__construct($container, $env, $inventory, $servers); + parent::__construct($container, $env, $inventory, $servers, $ssh); } /** @@ -64,6 +68,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'hr' => $this->hr(), 'writeln' => $this->writeln(...$this->testArgs), 'showCommandHint' => $this->showCommandHint(...$this->testArgs), + 'displayServerInfo' => $this->displayServerInfo(...$this->testArgs), 'getOptionOrPrompt' => $this->testGetOptionOrPrompt(), 'getOptionOrPromptEmpty' => $this->testGetOptionOrPromptEmpty(), 'getOptionOrPromptBoolean' => $this->testGetOptionOrPromptBoolean(), diff --git a/tests/Integration/Console/Server/ServerAddCommandTest.php b/tests/Integration/Console/Server/ServerAddCommandTest.php new file mode 100644 index 00000000..5e2dad00 --- /dev/null +++ b/tests/Integration/Console/Server/ServerAddCommandTest.php @@ -0,0 +1,352 @@ + []]); + $inventory->loadInventoryFile(); + + $repository = new \Bigpixelrocket\DeployerPHP\Repositories\ServerRepository(); + $repository->loadInventory($inventory); + + $ssh = $sshService ?? mockSSHService(); + + $command = new ServerAddCommand($container, $env, $inventory, $repository, $ssh); + return new CommandTester($command); +} + +// +// Integration tests +// ------------------------------------------------------------------------------- + +describe('ServerAddCommand', function () { + // + // Success Scenarios + // ------------------------------------------------------------------------------- + + it('adds server with all options provided non-interactively', function () { + // ARRANGE + $sshService = mockSSHServiceWithBehavior(true); + $tester = createServerAddCommandTester($sshService); + + // ACT - Capture all output including ANSI sequences from Laravel Prompts + ob_start(); + $exitCode = $tester->execute([ + '--name' => 'production-web', + '--host' => '192.168.1.100', + '--port' => '2222', + '--username' => 'deployer', + '--private-key-path' => '~/.ssh/prod_key', + '--yes' => true, + ]); + ob_end_clean(); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toContain('✓') + ->and($output)->toContain('Server added successfully') + ->and($output)->toContain('Run non-interactively:') + ->and($output)->toContain('server:add') + ->and($output)->toContain('production-web') + ->and($output)->toContain('192.168.1.100'); + }); + + it('adds server with minimal options using defaults', function () { + // ARRANGE + $sshService = mockSSHServiceWithBehavior(true); + $tester = createServerAddCommandTester($sshService); + + // ACT - Capture all output including ANSI sequences from Laravel Prompts + ob_start(); + $exitCode = $tester->execute([ + '--name' => 'web1', + '--host' => '192.168.1.1', + '--yes' => true, + ]); + ob_end_clean(); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toContain('Server added successfully') + ->and($output)->toContain('Port:') + ->and($output)->toContain('22') + ->and($output)->toContain('User:') + ->and($output)->toContain('root'); + }); + + it('adds server with successful SSH connection test', function () { + // ARRANGE + $sshService = mockSSHServiceWithBehavior(true); + $tester = createServerAddCommandTester($sshService); + + // ACT - Capture all output including ANSI sequences from Laravel Prompts + ob_start(); + $exitCode = $tester->execute([ + '--name' => 'test-server', + '--host' => '10.0.0.1', + '--skip' => false, + '--yes' => true, + ]); + ob_end_clean(); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toContain('✓') + ->and($output)->toContain('SSH connection successful') + ->and($output)->toContain('Server added successfully'); + }); + + it('adds server with skip flag bypassing SSH test', function () { + // ARRANGE + $sshService = mockSSHServiceWithBehavior(false); + $tester = createServerAddCommandTester($sshService); + + // ACT - Capture all output including ANSI sequences from Laravel Prompts + ob_start(); + $exitCode = $tester->execute([ + '--name' => 'untested-server', + '--host' => '192.168.1.50', + '--skip' => true, + '--yes' => true, + ]); + ob_end_clean(); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toContain('⚠') + ->and($output)->toContain('Skipping SSH connection check') + ->and($output)->toContain('Server added successfully'); + }); + + // + // Error Scenarios + // ------------------------------------------------------------------------------- + + it('rejects invalid host with helpful error message', function (string $invalidHost) { + // ARRANGE + $sshService = mockSSHServiceWithBehavior(true); + $tester = createServerAddCommandTester($sshService); + + // ACT & ASSERT + expect(fn () => $tester->execute([ + '--name' => 'test', + '--host' => $invalidHost, + '--yes' => true, + ]))->toThrow(\InvalidArgumentException::class, 'Invalid host'); + })->with([ + 'underscore' => ['server_name'], + 'spaces' => ['my server'], + 'special chars' => ['server!@#'], + ]); + + it('rejects invalid port with helpful error message', function (string $invalidPort) { + // ARRANGE + $sshService = mockSSHServiceWithBehavior(true); + $tester = createServerAddCommandTester($sshService); + + // ACT & ASSERT + expect(fn () => $tester->execute([ + '--name' => 'test', + '--host' => '192.168.1.1', + '--port' => $invalidPort, + '--yes' => true, + ]))->toThrow(\InvalidArgumentException::class, 'between 1 and 65535'); + })->with([ + 'zero' => ['0'], + 'negative' => ['-1'], + 'too high' => ['65536'], + 'way too high' => ['100000'], + ]); + + it('prevents duplicate server names', function () { + // ARRANGE + $sshService = mockSSHServiceWithBehavior(true); + $tester = createServerAddCommandTester($sshService); + + // ACT - Add first server (capture output) + ob_start(); + $tester->execute([ + '--name' => 'duplicate-name', + '--host' => '192.168.1.1', + '--yes' => true, + ]); + ob_end_clean(); + + // ACT - Try to add duplicate (capture output) + ob_start(); + $exitCode = $tester->execute([ + '--name' => 'duplicate-name', + '--host' => '192.168.1.2', + '--yes' => true, + ]); + ob_end_clean(); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::FAILURE) + ->and($output)->toContain('✗') + ->and($output)->toContain('Failed to add server') + ->and($output)->toContain('duplicate-name'); + }); + + it('handles SSH connection failure with troubleshooting tips', function () { + // ARRANGE + $sshService = mockSSHServiceWithBehavior(false); + $tester = createServerAddCommandTester($sshService); + + // ACT - Capture all output including ANSI sequences from Laravel Prompts + ob_start(); + $exitCode = $tester->execute([ + '--name' => 'unreachable', + '--host' => '192.168.1.99', + '--skip' => false, + '--yes' => true, + ]); + ob_end_clean(); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::FAILURE) + ->and($output)->toContain('✗') + ->and($output)->toContain('Common issues:') + ->and($output)->toContain('Check that the server is accessible') + ->and($output)->toContain('Verify SSH is running') + ->and($output)->toContain('Tip:') + ->and($output)->toContain('--skip'); + }); + + it('saves server when confirmation is given', function () { + // ARRANGE + $sshService = mockSSHServiceWithBehavior(true); + $tester = createServerAddCommandTester($sshService); + + // ACT - Capture all output including ANSI sequences from Laravel Prompts + ob_start(); + $exitCode = $tester->execute([ + '--name' => 'confirmed-server', + '--host' => '192.168.1.1', + '--skip' => true, + '--yes' => true, + ]); + ob_end_clean(); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toContain('✓') + ->and($output)->toContain('Server added successfully'); + }); + + // + // Inventory Persistence + // ------------------------------------------------------------------------------- + + it('persists server data to inventory correctly', function () { + // ARRANGE + $sshService = mockSSHServiceWithBehavior(true); + $container = new Container(); + $env = mockEnvService(true); + $inventory = mockInventoryService(true, ['servers' => []]); + $inventory->loadInventoryFile(); + + $repository = new \Bigpixelrocket\DeployerPHP\Repositories\ServerRepository(); + $repository->loadInventory($inventory); + + $command = new ServerAddCommand($container, $env, $inventory, $repository, $sshService); + $tester = new CommandTester($command); + + // ACT - Capture all output including ANSI sequences from Laravel Prompts + ob_start(); + $tester->execute([ + '--name' => 'persisted-server', + '--host' => '10.20.30.40', + '--port' => '8022', + '--username' => 'admin', + '--private-key-path' => '~/.ssh/admin_key', + '--yes' => true, + ]); + ob_end_clean(); + + // ASSERT - Verify server persisted in repository + $server = $repository->findByName('persisted-server'); + + expect($server)->not->toBeNull() + ->and($server->name)->toBe('persisted-server') + ->and($server->host)->toBe('10.20.30.40') + ->and($server->port)->toBe(8022) + ->and($server->username)->toBe('admin') + ->and($server->privateKeyPath)->toBe('~/.ssh/admin_key'); + }); + + it('displays complete server information before saving', function () { + // ARRANGE + $sshService = mockSSHServiceWithBehavior(true); + $tester = createServerAddCommandTester($sshService); + + // ACT + $tester->execute([ + '--name' => 'display-test', + '--host' => 'example.com', + '--port' => '22', + '--username' => 'deployer', + '--private-key-path' => '~/.ssh/key', + '--skip' => true, + '--yes' => true, + ]); + + // ASSERT + $output = $tester->getDisplay(); + expect($output)->toContain('Name:') + ->and($output)->toContain('display-test') + ->and($output)->toContain('Host:') + ->and($output)->toContain('example.com') + ->and($output)->toContain('Port:') + ->and($output)->toContain('22') + ->and($output)->toContain('User:') + ->and($output)->toContain('deployer') + ->and($output)->toContain('Key:') + ->and($output)->toContain('~/.ssh/key'); + }); + + it('shows default SSH key path when not provided', function () { + // ARRANGE + $sshService = mockSSHServiceWithBehavior(true); + $tester = createServerAddCommandTester($sshService); + + // ACT - Capture all output including ANSI sequences from Laravel Prompts + ob_start(); + $tester->execute([ + '--name' => 'default-key', + '--host' => '192.168.1.1', + '--skip' => true, + '--yes' => true, + ]); + ob_end_clean(); + + // ASSERT + $output = $tester->getDisplay(); + expect($output)->toContain('Key:') + ->and($output)->toContain('default') + ->and($output)->toContain('~/.ssh/id_ed25519') + ->and($output)->toContain('~/.ssh/id_rsa'); + }); +}); diff --git a/tests/Integration/Console/Server/ServerDeleteCommandTest.php b/tests/Integration/Console/Server/ServerDeleteCommandTest.php new file mode 100644 index 00000000..bf5b6804 --- /dev/null +++ b/tests/Integration/Console/Server/ServerDeleteCommandTest.php @@ -0,0 +1,229 @@ + []] : ['servers' => array_map( + fn (ServerDTO $server) => [ + 'name' => $server->name, + 'host' => $server->host, + 'port' => $server->port, + 'username' => $server->username, + 'privateKeyPath' => $server->privateKeyPath, + ], + $existingServers + )]; + + $inventory = mockInventoryService(true, $inventoryData); + $inventory->loadInventoryFile(); + + $repository = new \Bigpixelrocket\DeployerPHP\Repositories\ServerRepository(); + $repository->loadInventory($inventory); + + $ssh = mockSSHService(); + + $command = new ServerDeleteCommand($container, $env, $inventory, $repository, $ssh); + return new CommandTester($command); +} + +// +// Integration tests +// ------------------------------------------------------------------------------- + +describe('ServerDeleteCommand', function () { + // + // Success Scenarios + // ------------------------------------------------------------------------------- + + it('deletes server with name option non-interactively', function () { + // ARRANGE + $existingServers = [ + new ServerDTO('web1', '192.168.1.1', 22, 'root', null), + new ServerDTO('web2', '192.168.1.2', 22, 'root', null), + ]; + $tester = createServerDeleteCommandTester($existingServers); + + // ACT + $exitCode = $tester->execute([ + '--name' => 'web1', + '--yes' => true, + ]); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toContain('✓') + ->and($output)->toContain("Server 'web1' deleted successfully") + ->and($output)->toContain('Run non-interactively:') + ->and($output)->toContain('server:delete'); + }); + + it('deletes server with confirmation', function () { + // ARRANGE + $existingServers = [ + new ServerDTO('production', '10.0.0.1', 22, 'deployer', null), + ]; + $tester = createServerDeleteCommandTester($existingServers); + + // ACT + $exitCode = $tester->execute([ + '--name' => 'production', + '--yes' => true, + ]); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toContain('deleted successfully'); + }); + + it('deletes server when confirmation is provided', function () { + // ARRANGE + $existingServers = [ + new ServerDTO('delete-me', '192.168.1.1', 22, 'root', null), + ]; + $tester = createServerDeleteCommandTester($existingServers); + + // ACT + $exitCode = $tester->execute([ + '--name' => 'delete-me', + '--yes' => true, + ]); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toContain('✓') + ->and($output)->toContain('deleted successfully'); + }); + + // + // Error Scenarios + // ------------------------------------------------------------------------------- + + it('fails when deleting non-existent server', function () { + // ARRANGE + $existingServers = [ + new ServerDTO('existing', '192.168.1.1', 22, 'root', null), + ]; + $tester = createServerDeleteCommandTester($existingServers); + + // ACT + $exitCode = $tester->execute([ + '--name' => 'non-existent', + '--yes' => true, + ]); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::FAILURE) + ->and($output)->toContain('✗') + ->and($output)->toContain("Server 'non-existent' not found"); + }); + + it('handles empty inventory gracefully', function () { + // ARRANGE + $tester = createServerDeleteCommandTester([]); + + // ACT + $exitCode = $tester->execute([]); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toContain('⚠') + ->and($output)->toContain('No servers found in inventory') + ->and($output)->toContain('server:add'); + }); + + // + // Display Verification + // ------------------------------------------------------------------------------- + + it('displays server info before confirmation', function () { + // ARRANGE + $existingServers = [ + new ServerDTO('display-test', 'example.com', 2222, 'deployer', '~/.ssh/key'), + ]; + $tester = createServerDeleteCommandTester($existingServers); + + // ACT + $tester->execute([ + '--name' => 'display-test', + '--yes' => true, + ]); + + // ASSERT + $output = $tester->getDisplay(); + expect($output)->toContain('Name:') + ->and($output)->toContain('display-test') + ->and($output)->toContain('Host:') + ->and($output)->toContain('example.com') + ->and($output)->toContain('Port:') + ->and($output)->toContain('2222') + ->and($output)->toContain('User:') + ->and($output)->toContain('deployer') + ->and($output)->toContain('Key:') + ->and($output)->toContain('~/.ssh/key'); + }); + + it('displays default SSH key message when path is null', function () { + // ARRANGE + $existingServers = [ + new ServerDTO('default-key-server', '192.168.1.1', 22, 'root', null), + ]; + $tester = createServerDeleteCommandTester($existingServers); + + // ACT + $tester->execute([ + '--name' => 'default-key-server', + '--yes' => true, + ]); + + // ASSERT + $output = $tester->getDisplay(); + expect($output)->toContain('Key:') + ->and($output)->toContain('default') + ->and($output)->toContain('~/.ssh/id_ed25519') + ->and($output)->toContain('~/.ssh/id_rsa'); + }); + + it('shows command hint with correct parameters', function () { + // ARRANGE + $existingServers = [ + new ServerDTO('hint-test', '192.168.1.1', 22, 'root', null), + ]; + $tester = createServerDeleteCommandTester($existingServers); + + // ACT + $tester->execute([ + '--name' => 'hint-test', + '--yes' => true, + ]); + + // ASSERT + $output = $tester->getDisplay(); + expect($output)->toContain('Run non-interactively:') + ->and($output)->toContain('server:delete') + ->and($output)->toContain('--name') + ->and($output)->toContain('hint-test') + ->and($output)->toContain('--yes'); + }); +}); diff --git a/tests/Integration/Console/Server/ServerListCommandTest.php b/tests/Integration/Console/Server/ServerListCommandTest.php new file mode 100644 index 00000000..12ea1dad --- /dev/null +++ b/tests/Integration/Console/Server/ServerListCommandTest.php @@ -0,0 +1,205 @@ + []] : ['servers' => array_map( + fn (ServerDTO $server) => [ + 'name' => $server->name, + 'host' => $server->host, + 'port' => $server->port, + 'username' => $server->username, + 'privateKeyPath' => $server->privateKeyPath, + ], + $existingServers + )]; + + $inventory = mockInventoryService(true, $inventoryData); + $inventory->loadInventoryFile(); + + $repository = new \Bigpixelrocket\DeployerPHP\Repositories\ServerRepository(); + $repository->loadInventory($inventory); + + $ssh = mockSSHService(); + + $command = new \Bigpixelrocket\DeployerPHP\Console\Server\ServerListCommand($container, $env, $inventory, $repository, $ssh); + return new CommandTester($command); +} + +// +// Integration tests +// ------------------------------------------------------------------------------- + +describe('ServerListCommand', function () { + // + // Success Scenarios + // ------------------------------------------------------------------------------- + + it('lists multiple servers with full details', function () { + // ARRANGE + $existingServers = [ + new ServerDTO('web1', '192.168.1.1', 22, 'root', null), + new ServerDTO('web2', '192.168.1.2', 2222, 'deployer', '~/.ssh/custom'), + new ServerDTO('database', '10.0.0.5', 22, 'admin', null), + ]; + $tester = createServerListCommandTester($existingServers); + + // ACT + $exitCode = $tester->execute([]); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toContain('▸') + ->and($output)->toContain('All Servers') + ->and($output)->toContain('web1') + ->and($output)->toContain('192.168.1.1') + ->and($output)->toContain('web2') + ->and($output)->toContain('192.168.1.2') + ->and($output)->toContain('2222') + ->and($output)->toContain('deployer') + ->and($output)->toContain('database') + ->and($output)->toContain('10.0.0.5'); + }); + + it('lists single server with complete details', function () { + // ARRANGE + $existingServers = [ + new ServerDTO('production', 'prod.example.com', 8022, 'deploy', '~/.ssh/prod_key'), + ]; + $tester = createServerListCommandTester($existingServers); + + // ACT + $exitCode = $tester->execute([]); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toContain('All Servers') + ->and($output)->toContain('production') + ->and($output)->toContain('prod.example.com') + ->and($output)->toContain('8022') + ->and($output)->toContain('deploy') + ->and($output)->toContain('~/.ssh/prod_key'); + }); + + // + // Edge Cases + // ------------------------------------------------------------------------------- + + it('handles empty inventory gracefully', function () { + // ARRANGE + $tester = createServerListCommandTester([]); + + // ACT + $exitCode = $tester->execute([]); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toContain('⚠') + ->and($output)->toContain('No servers found in inventory') + ->and($output)->toContain('server:add') + ->and($output)->not->toContain('All Servers'); + }); + + it('displays default SSH key message for servers without custom keys', function () { + // ARRANGE + $existingServers = [ + new ServerDTO('default-key', '192.168.1.1', 22, 'root', null), + ]; + $tester = createServerListCommandTester($existingServers); + + // ACT + $exitCode = $tester->execute([]); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toContain('Key:') + ->and($output)->toContain('default') + ->and($output)->toContain('~/.ssh/id_ed25519') + ->and($output)->toContain('~/.ssh/id_rsa'); + }); + + it('displays custom SSH key paths correctly', function () { + // ARRANGE + $existingServers = [ + new ServerDTO('custom-key', '192.168.1.1', 22, 'root', '~/.ssh/special_key'), + ]; + $tester = createServerListCommandTester($existingServers); + + // ACT + $exitCode = $tester->execute([]); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toContain('Key:') + ->and($output)->toContain('~/.ssh/special_key') + ->and($output)->not->toContain('default'); + }); + + it('displays all server fields correctly', function () { + // ARRANGE + $existingServers = [ + new ServerDTO('full-details', 'server.example.com', 9022, 'sysadmin', '/home/user/.ssh/key'), + ]; + $tester = createServerListCommandTester($existingServers); + + // ACT + $exitCode = $tester->execute([]); + + // ASSERT + $output = $tester->getDisplay(); + expect($output)->toContain('Name:') + ->and($output)->toContain('full-details') + ->and($output)->toContain('Host:') + ->and($output)->toContain('server.example.com') + ->and($output)->toContain('Port:') + ->and($output)->toContain('9022') + ->and($output)->toContain('User:') + ->and($output)->toContain('sysadmin') + ->and($output)->toContain('Key:') + ->and($output)->toContain('/home/user/.ssh/key'); + }); + + it('lists servers in order they appear in inventory', function () { + // ARRANGE + $existingServers = [ + new ServerDTO('alpha', '192.168.1.1', 22, 'root', null), + new ServerDTO('beta', '192.168.1.2', 22, 'root', null), + new ServerDTO('gamma', '192.168.1.3', 22, 'root', null), + ]; + $tester = createServerListCommandTester($existingServers); + + // ACT + $exitCode = $tester->execute([]); + + // ASSERT + $output = $tester->getDisplay(); + $alphaPos = strpos($output, 'alpha'); + $betaPos = strpos($output, 'beta'); + $gammaPos = strpos($output, 'gamma'); + + expect($exitCode)->toBe(Command::SUCCESS) + ->and($alphaPos)->toBeLessThan($betaPos) + ->and($betaPos)->toBeLessThan($gammaPos); + }); +}); diff --git a/tests/TestHelpers.php b/tests/TestHelpers.php index 13fc5030..fc9a8a50 100644 --- a/tests/TestHelpers.php +++ b/tests/TestHelpers.php @@ -8,6 +8,7 @@ use Bigpixelrocket\DeployerPHP\Services\FilesystemService; use Bigpixelrocket\DeployerPHP\Services\InventoryService; use Bigpixelrocket\DeployerPHP\Services\ProcessFactory; +use Bigpixelrocket\DeployerPHP\Services\SSHService; use Bigpixelrocket\DeployerPHP\Services\VersionService; use Bigpixelrocket\DeployerPHP\Tests\Fixtures\TestConsoleCommand; use Symfony\Component\Dotenv\Dotenv; @@ -253,6 +254,90 @@ function mockServerRepository( } } +if (!function_exists('mockSSHService')) { + /** + * Create an SSHService for testing with mocked dependencies. + */ + function mockSSHService(): SSHService + { + $envService = mockEnvService(true); + $filesystemService = new FilesystemService(new Filesystem()); + + return new SSHService($envService, $filesystemService); + } +} + +if (!function_exists('mockSSHServiceWithBehavior')) { + /** + * Create a mock SSHService that simulates connection success/failure. + * + * @param bool $canConnect Whether SSH connection should succeed or fail + */ + function mockSSHServiceWithBehavior(bool $canConnect = true): SSHService + { + return new class ($canConnect) extends SSHService { + public function __construct(private readonly bool $canConnect) + { + // Skip parent constructor to avoid dependency injection + } + + public function assertCanConnect( + string $host, + int $port, + string $username, + ?string $privateKeyPath = null + ): void { + if (!$this->canConnect) { + throw new \RuntimeException('Failed to connect to SSH server'); + } + // Success - no exception thrown + } + + public function executeCommand( + string $host, + int $port, + string $username, + string $command, + ?string $privateKeyPath = null + ): array { + return ['output' => 'command output', 'exit_code' => 0]; + } + + public function executeScript( + string $host, + int $port, + string $username, + string $scriptPath, + ?string $privateKeyPath = null + ): array { + return ['output' => 'script output', 'exit_code' => 0]; + } + + public function uploadFile( + string $host, + int $port, + string $username, + string $localPath, + string $remotePath, + ?string $privateKeyPath = null + ): void { + // Mock implementation - no actual upload + } + + public function downloadFile( + string $host, + int $port, + string $username, + string $remotePath, + string $localPath, + ?string $privateKeyPath = null + ): void { + // Mock implementation - no actual download + } + }; + } +} + if (!function_exists('mockTestConsoleCommand')) { /** * Create a TestConsoleCommand for testing with mocked dependencies. @@ -267,7 +352,8 @@ function mockTestConsoleCommand( $env = mockEnvService($envFileExists, $envContent); $inventory = mockInventoryService($inventoryFileExists, $inventoryData); $servers = mockServerRepository($inventoryFileExists, $inventoryData); + $ssh = mockSSHService(); - return new TestConsoleCommand($container, $env, $inventory, $servers); + return new TestConsoleCommand($container, $env, $inventory, $servers, $ssh); } } diff --git a/tests/Unit/Contracts/BaseCommandTest.php b/tests/Unit/Contracts/BaseCommandTest.php index 1ab6202e..9fada588 100644 --- a/tests/Unit/Contracts/BaseCommandTest.php +++ b/tests/Unit/Contracts/BaseCommandTest.php @@ -9,6 +9,7 @@ use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository; use Bigpixelrocket\DeployerPHP\Services\EnvService; use Bigpixelrocket\DeployerPHP\Services\InventoryService; +use Bigpixelrocket\DeployerPHP\Services\SSHService; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -27,9 +28,10 @@ public function __construct( EnvService $env, InventoryService $inventory, ServerRepository $servers, + SSHService $ssh, private readonly string $testName = 'test-command', ) { - parent::__construct($container, $env, $inventory, $servers); + parent::__construct($container, $env, $inventory, $servers, $ssh); } protected function configure(): void @@ -57,9 +59,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $env = mockEnvService(true); $inventory = mockInventoryService(true); $servers = mockServerRepository(); + $ssh = mockSSHService(); // ACT - $command = new TestableBaseCommand($container, $env, $inventory, $servers, 'test'); + $command = new TestableBaseCommand($container, $env, $inventory, $servers, $ssh, 'test'); // ASSERT expect($command->getName())->toBe('test') @@ -77,7 +80,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $env = mockEnvService($hasEnvFile); $inventory = mockInventoryService(true); $servers = mockServerRepository(); - $command = new TestableBaseCommand($container, $env, $inventory, $servers); + $ssh = mockSSHService(); + $command = new TestableBaseCommand($container, $env, $inventory, $servers, $ssh); $tester = new CommandTester($command); // ACT diff --git a/tests/Unit/Traits/ServerHelpersTraitTest.php b/tests/Unit/Traits/ServerHelpersTraitTest.php new file mode 100644 index 00000000..6f7f2d98 --- /dev/null +++ b/tests/Unit/Traits/ServerHelpersTraitTest.php @@ -0,0 +1,84 @@ +command = mockTestConsoleCommand(); + $this->tester = new CommandTester($this->command); + }); + + // + // displayServerInfo + // ------------------------------------------------------------------------------- + + it('displays server information with all fields', function () { + // ARRANGE + $this->command->setTestMethod('displayServerInfo', [ + new ServerDTO( + name: 'production-web', + host: '192.168.1.100', + port: 2222, + username: 'deployer', + privateKeyPath: '~/.ssh/custom_key' + ), + ]); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('Name:') + ->and($output)->toContain('production-web') + ->and($output)->toContain('Host:') + ->and($output)->toContain('192.168.1.100') + ->and($output)->toContain('Port:') + ->and($output)->toContain('2222') + ->and($output)->toContain('User:') + ->and($output)->toContain('deployer') + ->and($output)->toContain('Key:') + ->and($output)->toContain('~/.ssh/custom_key'); + }); + + it('displays default SSH key message when privateKeyPath is null', function () { + // ARRANGE + $this->command->setTestMethod('displayServerInfo', [ + new ServerDTO(name: 'test-server', host: '127.0.0.1'), + ]); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('Key:') + ->and($output)->toContain('default') + ->and($output)->toContain('~/.ssh/id_ed25519') + ->and($output)->toContain('~/.ssh/id_rsa'); + }); + + it('displays server info with default values', function () { + // ARRANGE + $this->command->setTestMethod('displayServerInfo', [ + new ServerDTO(name: 'minimal', host: 'example.com'), + ]); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('minimal') + ->and($output)->toContain('example.com') + ->and($output)->toContain('22') + ->and($output)->toContain('root'); + }); +}); diff --git a/tests/Unit/Traits/ServerValidationTraitTest.php b/tests/Unit/Traits/ServerValidationTraitTest.php new file mode 100644 index 00000000..f250416c --- /dev/null +++ b/tests/Unit/Traits/ServerValidationTraitTest.php @@ -0,0 +1,154 @@ +validateHost($host); + } + + /** + * Expose protected validatePort for testing. + */ + public function testValidatePort(int $port): void + { + $this->validatePort($port); + } +} + +// +// Unit tests +// ------------------------------------------------------------------------------- + +describe('ServerValidationTrait', function () { + beforeEach(function () { + $this->validator = new TestServerValidator(); + }); + + // + // validateHost + // ------------------------------------------------------------------------------- + + it('accepts valid IPv4 addresses', function (string $host) { + // ARRANGE & ACT & ASSERT + expect(fn () => $this->validator->testValidateHost($host))->not->toThrow(\InvalidArgumentException::class); + })->with([ + 'standard IPv4' => ['192.168.1.100'], + 'localhost' => ['127.0.0.1'], + 'zero address' => ['0.0.0.0'], + 'broadcast' => ['255.255.255.255'], + ]); + + it('accepts valid IPv6 addresses', function (string $host) { + // ARRANGE & ACT & ASSERT + expect(fn () => $this->validator->testValidateHost($host))->not->toThrow(\InvalidArgumentException::class); + })->with([ + 'full IPv6' => ['2001:0db8:85a3:0000:0000:8a2e:0370:7334'], + 'compressed IPv6' => ['2001:db8::1'], + 'localhost' => ['::1'], + ]); + + it('accepts valid domain names', function (string $host) { + // ARRANGE & ACT & ASSERT + expect(fn () => $this->validator->testValidateHost($host))->not->toThrow(\InvalidArgumentException::class); + })->with([ + 'simple domain' => ['example.com'], + 'subdomain' => ['server.example.com'], + 'deep subdomain' => ['app.server.example.com'], + 'hyphenated domain' => ['my-server.example.com'], + 'numeric in domain' => ['server1.example.com'], + ]); + + it('rejects invalid hosts', function (string $host) { + // ARRANGE & ACT & ASSERT + expect(fn () => $this->validator->testValidateHost($host)) + ->toThrow(\InvalidArgumentException::class, 'Invalid host'); + })->with([ + 'empty string' => [''], + 'underscore' => ['server_name'], + 'spaces' => ['my server'], + 'special chars' => ['server!@#'], + 'double dots' => ['example..com'], + ]); + + it('provides helpful error message for invalid hosts', function () { + // ARRANGE + $invalidHost = 'invalid_host'; + + // ACT & ASSERT + try { + $this->validator->testValidateHost($invalidHost); + throw new \Exception('Expected InvalidArgumentException was not thrown'); + } catch (\InvalidArgumentException $e) { + expect($e->getMessage()) + ->toContain('Invalid host') + ->and($e->getMessage())->toContain($invalidHost) + ->and($e->getMessage())->toContain('Examples:') + ->and($e->getMessage())->toContain('192.168.1.100') + ->and($e->getMessage())->toContain('example.com'); + } + }); + + // + // validatePort + // ------------------------------------------------------------------------------- + + it('accepts valid port numbers', function (int $port) { + // ARRANGE & ACT & ASSERT + expect(fn () => $this->validator->testValidatePort($port))->not->toThrow(\InvalidArgumentException::class); + })->with([ + 'SSH default' => [22], + 'HTTP' => [80], + 'HTTPS' => [443], + 'custom high' => [8080], + 'alternative SSH' => [2222], + 'minimum port' => [1], + 'maximum port' => [65535], + ]); + + it('rejects invalid port numbers', function (int $port) { + // ARRANGE & ACT & ASSERT + expect(fn () => $this->validator->testValidatePort($port)) + ->toThrow(\InvalidArgumentException::class, 'between 1 and 65535'); + })->with([ + 'zero' => [0], + 'negative' => [-1], + 'large negative' => [-100], + 'too high' => [65536], + 'way too high' => [100000], + ]); + + it('provides helpful error message for invalid ports', function () { + // ARRANGE + $invalidPort = 99999; + + // ACT & ASSERT + try { + $this->validator->testValidatePort($invalidPort); + throw new \Exception('Expected InvalidArgumentException was not thrown'); + } catch (\InvalidArgumentException $e) { + expect($e->getMessage()) + ->toContain('Invalid port') + ->and($e->getMessage())->toContain((string) $invalidPort) + ->and($e->getMessage())->toContain('between 1 and 65535') + ->and($e->getMessage())->toContain('Common SSH ports:') + ->and($e->getMessage())->toContain('22 (default)') + ->and($e->getMessage())->toContain('2222'); + } + }); +});