diff --git a/.cursor/rules/02-tests.mdc b/.cursor/rules/02-tests.mdc index 6c41db03..ed3eee44 100644 --- a/.cursor/rules/02-tests.mdc +++ b/.cursor/rules/02-tests.mdc @@ -130,7 +130,8 @@ it('does something specific', function () { ```php expect($x)->toBeInstanceOf(Class::class); // Type-only testing expect($x)->toBeArray(); // Generic assertions -expect($x)->not->toBeNull(); // Meaningless +expect($x)->not->toBeNull(); // Meaningless on its own +expect($x)->not->toBeNull()->and($x)->toContain('text'); // Redundant (toContain already guarantees non-null) expect(true)->toBeTrue(); // Literally meaningless sleep(...); // Use time mocking ``` diff --git a/.cursor/rules/03-commands.mdc b/.cursor/rules/03-commands.mdc index 29417ffe..f6f0bfa6 100644 --- a/.cursor/rules/03-commands.mdc +++ b/.cursor/rules/03-commands.mdc @@ -63,7 +63,7 @@ protected function success(string $message): void { - Input trait: [ConsoleInputTrait.php](mdc:app/Traits/ConsoleInputTrait.php) - Output methods: `writeln()`, `info()`, `hr()`, `h1()` - Status methods: `success()`, `error()`, `warning()` -- Input methods: `getOptionOrPrompt()`, `promptText()`, `promptPassword()`, `promptConfirm()`, `promptSelect()`, `promptMultiselect()`, `promptSuggest()`, `promptSearch()`, `promptPause()`, `promptSpin()` +- Input methods: `getOptionOrPrompt()`, `getValidatedOptionOrPrompt()`, `promptText()`, `promptPassword()`, `promptConfirm()`, `promptSelect()`, `promptMultiselect()`, `promptSuggest()`, `promptSearch()`, `promptPause()`, `promptSpin()` - Helper methods: `showCommandHint()` **When to Add New Methods:** @@ -295,6 +295,144 @@ All wrappers automatically suppress extra spacing for cleaner output. - Type-safe - preserves return types from prompts - DRY - single command serves both use cases +### 🎯 Input Validation Pattern + +Validation traits provide reusable validation logic that integrates with both Laravel Prompts and CLI options. + +**Core Pattern:** + +Validation methods accept `mixed`, check type first, then return `?string` (error message or `null` if valid): + +```php +// ✅ CORRECT - Accept mixed with type guard +protected function validateNameInput(mixed $name): ?string +{ + if (!is_string($name)) { + return 'Server name must be a string'; + } + + if (trim($name) === '') { + return 'Server name cannot be empty'; + } + + // Check uniqueness + $existing = $this->servers->findByName($name); + if ($existing !== null) { + return "Server '{$name}' already exists"; + } + + return null; +} + +// ❌ WRONG - Throws exception (not compatible with Laravel Prompts) +protected function validateName(string $name): void +{ + if (trim($name) === '') { + throw new \InvalidArgumentException('Name cannot be empty'); + } +} + +// ❌ WRONG - Type-hinted as string (not PHPStan-compliant) +protected function validateNameInput(string $name): ?string +{ + // ... +} +``` + +**Using Validated Inputs:** + +Use `getValidatedOptionOrPrompt()` for inputs that require validation: + +```php +$name = $this->getValidatedOptionOrPrompt( + 'name', + fn ($validate) => $this->promptText( + label: 'Server name:', + placeholder: 'web1', + validate: $validate + ), + fn ($value) => $this->validateNameInput($value) +); + +if ($name === null) { + return Command::FAILURE; +} +``` + +**How It Works:** + +1. Validator is passed only once (third parameter) +2. Method automatically injects validator into prompt callback +3. Prompts validate interactively as user types +4. CLI options are validated after retrieval +5. Returns `null` on validation failure (error already displayed) +6. Returns validated value on success + +**Naming Convention:** + +- Validation methods: `validate*Input()` (returns `?string`) +- Exception-throwing validators: `validate*()` (for heavy I/O operations like git repo checks) + +**Examples:** + +- [ServerValidationTrait.php](mdc:app/Traits/ServerValidationTrait.php) - `validateHostInput()`, `validatePortInput()`, `validateNameInput()` +- [SiteValidationTrait.php](mdc:app/Traits/SiteValidationTrait.php) - `validateDomainInput()`, `validateBranchInput()` + +**When to Use Exceptions:** + +For validation that involves heavy I/O operations (network calls, external processes), use exception-throwing methods: + +```php +// Heavy I/O operation - throw exceptions +protected function validateGitRepo(string $repo): void +{ + $process = $this->proc->run(['git', 'ls-remote', '--exit-code', $repo]); + + if (!$process->isSuccessful()) { + throw new \RuntimeException("Cannot access git repository '{$repo}'"); + } +} + +// Used in commands with try-catch for user-friendly error display +try { + $this->validateGitRepo($repo); + $this->success('Git repository is accessible'); +} catch (\RuntimeException $e) { + $this->error($e->getMessage()); + return Command::FAILURE; +} +``` + +**Testing Validation Traits:** + +Create a test fixture class that uses the trait and exposes methods: + +```php +class TestServerValidator +{ + use ServerValidationTrait; + + public function testValidateHost(mixed $host): ?string + { + return $this->validateHostInput($host); + } +} + +// Test valid inputs return null +expect($validator->testValidateHost('192.168.1.100'))->toBeNull(); + +// Test invalid inputs return error messages +expect($validator->testValidateHost('invalid'))->toContain('valid'); +``` + +**Benefits:** + +- **Testable:** Easy to unit test without exception handling +- **Reusable:** Same method works for interactive prompts AND CLI options +- **User-friendly:** Integrates with Laravel Prompts' `validate` callback +- **Consistent:** Uniform pattern across all validation logic +- **Type-safe:** PHPStan compliant with proper type variance + **See also:** "Command Options & Input" section below for mandatory naming conventions. ### ⚙️ Command Options & Input (MANDATORY) diff --git a/app/Console/Server/ServerAddCommand.php b/app/Console/Server/ServerAddCommand.php index db936e64..2c1b0b04 100644 --- a/app/Console/Server/ServerAddCommand.php +++ b/app/Console/Server/ServerAddCommand.php @@ -58,40 +58,55 @@ protected function execute(InputInterface $input, OutputInterface $output): int // // Gather server details - /** @var string $name */ - $name = $this->getOptionOrPrompt( + /** @var string|null $name */ + $name = $this->getValidatedOptionOrPrompt( 'name', - fn (): string => $this->promptText( + fn ($validate) => $this->promptText( label: 'Server name:', placeholder: 'web1', - required: true - ) + required: true, + validate: $validate + ), + fn ($value) => $this->validateNameInput($value) ); - /** @var string $host */ - $host = $this->getOptionOrPrompt( + if ($name === null) { + return Command::FAILURE; + } + + /** @var string|null $host */ + $host = $this->getValidatedOptionOrPrompt( 'host', - fn (): string => $this->promptText( + fn ($validate) => $this->promptText( label: 'Host/IP address:', placeholder: '192.168.1.100', - required: true - ) + required: true, + validate: $validate + ), + fn ($value) => $this->validateHostInput($value) ); - $this->validateHost($host); + if ($host === null) { + return Command::FAILURE; + } - /** @var string $portString */ - $portString = $this->getOptionOrPrompt( + /** @var string|null $portString */ + $portString = $this->getValidatedOptionOrPrompt( 'port', - fn (): string => $this->promptText( + fn ($validate) => $this->promptText( label: 'SSH port:', default: '22', - required: true - ) + required: true, + validate: $validate + ), + fn ($value) => $this->validatePortInput($value) ); + if ($portString === null) { + return Command::FAILURE; + } + $port = (int) $portString; - $this->validatePort($port); /** @var string $username */ $username = $this->getOptionOrPrompt( diff --git a/app/Repositories/ServerRepository.php b/app/Repositories/ServerRepository.php index ec8145bd..d9278762 100644 --- a/app/Repositories/ServerRepository.php +++ b/app/Repositories/ServerRepository.php @@ -49,11 +49,16 @@ public function create(ServerDTO $server): void { $this->assertInventoryLoaded(); - $existing = $this->findByName($server->name); - if (null !== $existing) { + $existingName = $this->findByName($server->name); + if (null !== $existingName) { throw new \RuntimeException("Server '{$server->name}' already exists"); } + $existingHost = $this->findByHost($server->host); + if (null !== $existingHost) { + throw new \RuntimeException("Host '{$server->host}' is already used by server '{$existingHost->name}'"); + } + $this->servers[] = $this->dehydrateServerDTO($server); $this->inventory->set(self::PREFIX, $this->servers); @@ -75,6 +80,22 @@ public function findByName(string $name): ?ServerDTO return null; } + /** + * Find a server by host. + */ + public function findByHost(string $host): ?ServerDTO + { + $this->assertInventoryLoaded(); + + foreach ($this->servers as $server) { + if (isset($server['host']) && $server['host'] === $host) { + return $this->hydrateServerDTO($server); + } + } + + return null; + } + /** * Get all servers from the inventory. * diff --git a/app/Traits/ConsoleInputTrait.php b/app/Traits/ConsoleInputTrait.php index 9c192dce..29236d8d 100644 --- a/app/Traits/ConsoleInputTrait.php +++ b/app/Traits/ConsoleInputTrait.php @@ -98,6 +98,55 @@ protected function getOptionOrPrompt( return $promptCallback(); } + /** + * Get option value or prompt user, with automatic validation. + * + * Combines getOptionOrPrompt with validation. The validator is automatically + * applied to both interactive prompts and CLI options. + * + * @param string $optionName The option name to check + * @param Closure(Closure): mixed $promptCallback Closure that receives validator and returns prompt result + * @param Closure(mixed): ?string $validator Validation closure that returns error message or null + * + * @return mixed The validated value, or null if validation failed + * + * @example + * $name = $this->getValidatedOptionOrPrompt( + * 'name', + * fn($validate) => $this->promptText( + * label: 'Server name:', + * validate: $validate + * ), + * fn($value) => $this->validateNameInput($value) + * ); + * if ($name === null) { + * return Command::FAILURE; + * } + */ + protected function getValidatedOptionOrPrompt( + string $optionName, + Closure $promptCallback, + Closure $validator + ): mixed { + // Pass validator to prompt callback + $value = $this->getOptionOrPrompt( + $optionName, + fn () => $promptCallback($validator) + ); + + // Validate if value came from CLI option (prompts already validated) + if ($this->input->getOption($optionName) !== null) { + $error = $validator($value); + if ($error !== null) { + $this->error($error); + + return null; + } + } + + return $value; + } + // // Laravel Prompts Wrappers // ------------------------------------------------------------------------------- diff --git a/app/Traits/ServerHelpersTrait.php b/app/Traits/ServerHelpersTrait.php index 0bcae4e0..ca8d9b82 100644 --- a/app/Traits/ServerHelpersTrait.php +++ b/app/Traits/ServerHelpersTrait.php @@ -19,22 +19,7 @@ trait ServerHelpersTrait { /** - * Display server details. - */ - protected function displayServerDeets(ServerDTO $server): void - { - $this->writeln([ - " Name: {$server->name}", - " Host: {$server->host}", - " Port: {$server->port}", - " User: {$server->username}", - ' Key: '.($server->privateKeyPath ?? 'default (~/.ssh/id_ed25519 or ~/.ssh/id_rsa)').'', - ' ' - ]); - } - - /** - * Select a server from inventory by server option or interactive prompt. + * Select a server from inventory by name option or interactive prompt. * * @return array{server: ServerDTO|null, exit_code: int} Server DTO and exit code (SUCCESS if empty inventory, FAILURE if not found) */ @@ -71,13 +56,7 @@ protected function selectServer(string $optionName = 'server', string $promptLab // // Find server by name - $server = null; - foreach ($allServers as $s) { - if ($s->name === $name) { - $server = $s; - break; - } - } + $server = $this->servers->findByName($name); if ($server === null) { $this->error("Server '{$name}' not found in inventory"); @@ -88,4 +67,19 @@ protected function selectServer(string $optionName = 'server', string $promptLab return ['server' => $server, 'exit_code' => Command::SUCCESS]; } + /** + * Display server details. + */ + protected function displayServerDeets(ServerDTO $server): void + { + $this->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 index 27236148..c45a0ac5 100644 --- a/app/Traits/ServerValidationTrait.php +++ b/app/Traits/ServerValidationTrait.php @@ -10,35 +10,78 @@ trait ServerValidationTrait { /** - * Validate host is a valid IP or domain. + * Validate server name format and uniqueness. * - * @throws \InvalidArgumentException When host is invalid + * @return string|null Error message if invalid, null if valid */ - protected function validateHost(string $host): void + protected function validateNameInput(mixed $name): ?string { + if (!is_string($name)) { + return 'Server name must be a string'; + } + + // Check if empty + if (trim($name) === '') { + return 'Server name cannot be empty'; + } + + // Check uniqueness + $existing = $this->servers->findByName($name); + if ($existing !== null) { + return "Server '{$name}' already exists in inventory"; + } + + return null; + } + + /** + * Validate host is a valid IP or domain and unique. + * + * @return string|null Error message if invalid, null if valid + */ + protected function validateHostInput(mixed $host): ?string + { + if (!is_string($host)) { + return 'Host must be a string'; + } + + // Check format $isValidIp = filter_var($host, FILTER_VALIDATE_IP) !== false; $isValidDomain = filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false; if (!$isValidIp && !$isValidDomain) { - throw new \InvalidArgumentException( - "Invalid host '{$host}'. Must be a valid IP address or domain name.\n". - 'Examples: 192.168.1.100, example.com, server.example.com' - ); + return 'Must be a valid IP address or domain name (e.g., 192.168.1.100, example.com)'; } + + // Check uniqueness + $existing = $this->servers->findByHost($host); + if ($existing !== null) { + return "Host '{$host}' is already used by server '{$existing->name}'"; + } + + return null; } /** * Validate port is in valid range. * - * @throws \InvalidArgumentException When port is out of range + * @return string|null Error message if invalid, null if valid */ - protected function validatePort(int $port): void + protected function validatePortInput(mixed $portString): ?string { + if (!is_string($portString)) { + return 'Port must be a string'; + } + + if (!ctype_digit($portString)) { + return 'Port must be a number'; + } + + $port = (int) $portString; if ($port < 1 || $port > 65535) { - throw new \InvalidArgumentException( - "Invalid port {$port}. Port must be between 1 and 65535.\n". - 'Common SSH ports: 22 (default), 2222, 22000' - ); + return 'Port must be between 1 and 65535 (common SSH ports: 22, 2222, 22000)'; } + + return null; } } diff --git a/tests/Fixtures/TestConsoleCommand.php b/tests/Fixtures/TestConsoleCommand.php index 350eda01..c3066af0 100644 --- a/tests/Fixtures/TestConsoleCommand.php +++ b/tests/Fixtures/TestConsoleCommand.php @@ -91,6 +91,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'getOptionOrPromptEmpty' => $this->testGetOptionOrPromptEmpty(), 'getOptionOrPromptBoolean' => $this->testGetOptionOrPromptBoolean(), 'getOptionOrPromptTypes' => $this->testGetOptionOrPromptTypes(), + 'getValidatedOptionOrPromptValid' => $this->testGetValidatedOptionOrPromptValid(), + 'getValidatedOptionOrPromptInvalid' => $this->testGetValidatedOptionOrPromptInvalid(), 'testPromptSpin' => $this->testPromptSpinWrapper(), 'promptText' => $this->testPromptTextWrapper(), 'promptPassword' => $this->testPromptPasswordWrapper(), @@ -173,6 +175,38 @@ private function testGetOptionOrPromptTypes(): void } } + /** + * Test getValidatedOptionOrPrompt with valid input. + */ + private function testGetValidatedOptionOrPromptValid(): void + { + $result = $this->getValidatedOptionOrPrompt( + 'name', + fn ($validate) => $this->promptText(label: 'Name:', validate: $validate), + fn ($value) => trim((string) $value) === '' ? 'Cannot be empty' : null + ); + + if ($result === null) { + $this->io->text('Result: null'); + } else { + $this->io->text("Result: {$result}"); + } + } + + /** + * Test getValidatedOptionOrPrompt with invalid input. + */ + private function testGetValidatedOptionOrPromptInvalid(): void + { + $result = $this->getValidatedOptionOrPrompt( + 'name', + fn ($validate) => $this->promptText(label: 'Name:', validate: $validate), + fn ($value) => 'Always invalid' + ); + + $this->io->text('Result: '.($result ?? 'null')); + } + /** * Test promptSpin wrapper. */ diff --git a/tests/Integration/Console/Server/ServerAddCommandTest.php b/tests/Integration/Console/Server/ServerAddCommandTest.php index 1f4c44c6..9175e8e3 100644 --- a/tests/Integration/Console/Server/ServerAddCommandTest.php +++ b/tests/Integration/Console/Server/ServerAddCommandTest.php @@ -147,8 +147,9 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe $sshService = mockSSHServiceWithBehavior(true); $tester = createServerAddCommandTester($sshService); - // ACT & ASSERT - Provide all required options - expect(fn () => $tester->execute([ + // ACT + ob_start(); + $exitCode = $tester->execute([ '--name' => 'test', '--host' => $invalidHost, '--port' => '22', @@ -156,7 +157,14 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe '--private-key-path' => '', '--skip' => true, '--yes' => true, - ]))->toThrow(\InvalidArgumentException::class, 'Invalid host'); + ]); + ob_end_clean(); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::FAILURE) + ->and($output)->toContain('✗') + ->and($output)->toContain('valid'); })->with([ 'underscore' => ['server_name'], 'spaces' => ['my server'], @@ -168,8 +176,9 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe $sshService = mockSSHServiceWithBehavior(true); $tester = createServerAddCommandTester($sshService); - // ACT & ASSERT - Provide all required options - expect(fn () => $tester->execute([ + // ACT + ob_start(); + $exitCode = $tester->execute([ '--name' => 'test', '--host' => '192.168.1.1', '--port' => $invalidPort, @@ -177,7 +186,14 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe '--private-key-path' => '', '--skip' => true, '--yes' => true, - ]))->toThrow(\InvalidArgumentException::class, 'between 1 and 65535'); + ]); + ob_end_clean(); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::FAILURE) + ->and($output)->toContain('✗') + ->and($output)->toMatch('/Port must be|between 1 and 65535/'); })->with([ 'zero' => ['0'], 'negative' => ['-1'], @@ -220,10 +236,49 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe $output = $tester->getDisplay(); expect($exitCode)->toBe(Command::FAILURE) ->and($output)->toContain('✗') - ->and($output)->toContain('Failed to add server') + ->and($output)->toContain('already exists') ->and($output)->toContain('duplicate-name'); }); + it('prevents duplicate server hosts', function () { + // ARRANGE + $sshService = mockSSHServiceWithBehavior(true); + $tester = createServerAddCommandTester($sshService); + + // ACT - Add first server (capture output) + ob_start(); + $tester->execute([ + '--name' => 'server-one', + '--host' => '192.168.1.100', + '--port' => '22', + '--username' => 'root', + '--private-key-path' => '', + '--skip' => true, + '--yes' => true, + ]); + ob_end_clean(); + + // ACT - Try to add different server with same host (capture output) + ob_start(); + $exitCode = $tester->execute([ + '--name' => 'server-two', + '--host' => '192.168.1.100', + '--port' => '22', + '--username' => 'root', + '--private-key-path' => '', + '--skip' => true, + '--yes' => true, + ]); + ob_end_clean(); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::FAILURE) + ->and($output)->toContain('✗') + ->and($output)->toContain('already used by server') + ->and($output)->toContain('server-one'); + }); + it('handles SSH connection failure with troubleshooting tips', function () { // ARRANGE $sshService = mockSSHServiceWithBehavior(false); diff --git a/tests/Unit/Repositories/ServerRepositoryTest.php b/tests/Unit/Repositories/ServerRepositoryTest.php index 8b357bb5..2029cd72 100644 --- a/tests/Unit/Repositories/ServerRepositoryTest.php +++ b/tests/Unit/Repositories/ServerRepositoryTest.php @@ -48,8 +48,19 @@ ->and($found->username)->toBe('deployer') ->and($found->privateKeyPath)->toBe('~/.ssh/key'); + // ASSERT - Find by host + $foundByHost = $repository->findByHost('192.168.1.1'); + expect($foundByHost)->not->toBeNull() + ->and($foundByHost->name)->toBe('web1') + ->and($foundByHost->host)->toBe('192.168.1.1'); + + $foundByHost2 = $repository->findByHost('192.168.1.2'); + expect($foundByHost2)->not->toBeNull() + ->and($foundByHost2->name)->toBe('web2'); + // ASSERT - Find returns null for missing expect($repository->findByName('nonexistent'))->toBeNull(); + expect($repository->findByHost('10.0.0.1'))->toBeNull(); // ASSERT - All returns both servers $all = $repository->all(); @@ -81,6 +92,20 @@ ->toThrow(\RuntimeException::class, "Server 'existing' already exists"); }); + it('prevents duplicate server hosts', function () { + // ARRANGE + $inventory = mockInventoryService(true, ['servers' => [ + ['name' => 'existing', 'host' => '192.168.1.1', 'port' => 22, 'username' => 'root', 'privateKeyPath' => null], + ]]); + $inventory->loadInventoryFile(); + $repository = new ServerRepository(); + $repository->loadInventory($inventory); + + // ACT & ASSERT + expect(fn () => $repository->create(new ServerDTO('different-name', '192.168.1.1'))) + ->toThrow(\RuntimeException::class, "Host '192.168.1.1' is already used by server 'existing'"); + }); + // // Data Hydration Robustness // ------------------------------------------------------------------------------- diff --git a/tests/Unit/Traits/ConsoleInputTraitTest.php b/tests/Unit/Traits/ConsoleInputTraitTest.php index 41cbb10a..6ba55b47 100644 --- a/tests/Unit/Traits/ConsoleInputTraitTest.php +++ b/tests/Unit/Traits/ConsoleInputTraitTest.php @@ -133,4 +133,72 @@ // ASSERT expect($output)->toContain('Spin result: success'); }); + + // + // getValidatedOptionOrPrompt + // ------------------------------------------------------------------------------- + + describe('getValidatedOptionOrPrompt', function () { + beforeEach(function () { + $container = mockCommandContainer(); + $this->command = $container->build(\Bigpixelrocket\DeployerPHP\Tests\Fixtures\TestConsoleCommand::class); + $this->tester = new CommandTester($this->command); + }); + + it('returns validated value when CLI option is valid', function () { + // ARRANGE + $this->command->setTestMethod('getValidatedOptionOrPromptValid'); + + // ACT + $this->tester->execute(['--name' => 'valid-name']); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('Result: valid-name'); + }); + + it('returns null and shows error when CLI option is invalid', function () { + // ARRANGE + $this->command->setTestMethod('getValidatedOptionOrPromptInvalid'); + + // ACT + $this->tester->execute(['--name' => 'anything']); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('✗') + ->and($output)->toContain('Always invalid') + ->and($output)->toContain('Result: null'); + }); + + it('returns null when CLI option fails validation', function () { + // ARRANGE + $this->command->setTestMethod('getValidatedOptionOrPromptValid'); + + // ACT + $this->tester->execute(['--name' => '']); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('✗') + ->and($output)->toContain('Cannot be empty') + ->and($output)->toContain('Result: null'); + }); + + it('validator is passed to prompt callback in non-interactive mode', function () { + // ARRANGE - Create command with mock prompter that has a value queued + $mockPrompter = mockPrompter(text: ['prompted-value']); + $container = mockCommandContainer(prompter: $mockPrompter); + $command = $container->build(\Bigpixelrocket\DeployerPHP\Tests\Fixtures\TestConsoleCommand::class); + $tester = new CommandTester($command); + $command->setTestMethod('getValidatedOptionOrPromptValid'); + + // ACT - No --name option, will use prompter + $tester->execute([]); + $output = $tester->getDisplay(); + + // ASSERT - MockPrompter returned value, validator was passed and validated + expect($output)->toContain('Result: prompted-value'); + }); + }); }); diff --git a/tests/Unit/Traits/ServerValidationTraitTest.php b/tests/Unit/Traits/ServerValidationTraitTest.php index f250416c..bfec1cfc 100644 --- a/tests/Unit/Traits/ServerValidationTraitTest.php +++ b/tests/Unit/Traits/ServerValidationTraitTest.php @@ -14,20 +14,30 @@ class TestServerValidator { use ServerValidationTrait; + public $servers; + /** - * Expose protected validateHost for testing. + * Expose protected validateNameInput for testing. */ - public function testValidateHost(string $host): void + public function testValidateName(mixed $name): ?string { - $this->validateHost($host); + return $this->validateNameInput($name); } /** - * Expose protected validatePort for testing. + * Expose protected validateHostInput for testing. */ - public function testValidatePort(int $port): void + public function testValidateHost(mixed $host): ?string { - $this->validatePort($port); + return $this->validateHostInput($host); + } + + /** + * Expose protected validatePortInput for testing. + */ + public function testValidatePort(mixed $portString): ?string + { + return $this->validatePortInput($portString); } } @@ -35,18 +45,73 @@ public function testValidatePort(int $port): void // Unit tests // ------------------------------------------------------------------------------- +require_once __DIR__ . '/../../TestHelpers.php'; + describe('ServerValidationTrait', function () { beforeEach(function () { $this->validator = new TestServerValidator(); }); // - // validateHost + // validateNameInput + // ------------------------------------------------------------------------------- + + it('accepts valid server names', function (string $name) { + // ARRANGE + $this->validator->servers = mockServerRepository(true, ['servers' => []]); + + // ACT + $error = $this->validator->testValidateName($name); + + // ASSERT + expect($error)->toBeNull(); + })->with([ + 'simple name' => ['web1'], + 'hyphenated' => ['web-server-01'], + 'underscored' => ['web_server_01'], + 'numeric' => ['server123'], + 'mixed case' => ['WebServer01'], + ]); + + it('rejects empty server names', function () { + // ARRANGE + $this->validator->servers = mockServerRepository(true, ['servers' => []]); + + // ACT + $error = $this->validator->testValidateName(''); + + // ASSERT + expect($error)->toContain('cannot be empty'); + }); + + it('rejects duplicate server names', function () { + // ARRANGE + $this->validator->servers = mockServerRepository(true, [ + 'servers' => [ + ['name' => 'existing-server', 'host' => '192.168.1.1', 'port' => 22, 'username' => 'root'], + ], + ]); + + // ACT + $error = $this->validator->testValidateName('existing-server'); + + // ASSERT + expect($error)->toContain('already exists'); + }); + + // + // validateHostInput // ------------------------------------------------------------------------------- it('accepts valid IPv4 addresses', function (string $host) { - // ARRANGE & ACT & ASSERT - expect(fn () => $this->validator->testValidateHost($host))->not->toThrow(\InvalidArgumentException::class); + // ARRANGE + $this->validator->servers = mockServerRepository(true, ['servers' => []]); + + // ACT + $error = $this->validator->testValidateHost($host); + + // ASSERT + expect($error)->toBeNull(); })->with([ 'standard IPv4' => ['192.168.1.100'], 'localhost' => ['127.0.0.1'], @@ -55,8 +120,14 @@ public function testValidatePort(int $port): void ]); it('accepts valid IPv6 addresses', function (string $host) { - // ARRANGE & ACT & ASSERT - expect(fn () => $this->validator->testValidateHost($host))->not->toThrow(\InvalidArgumentException::class); + // ARRANGE + $this->validator->servers = mockServerRepository(true, ['servers' => []]); + + // ACT + $error = $this->validator->testValidateHost($host); + + // ASSERT + expect($error)->toBeNull(); })->with([ 'full IPv6' => ['2001:0db8:85a3:0000:0000:8a2e:0370:7334'], 'compressed IPv6' => ['2001:db8::1'], @@ -64,8 +135,14 @@ public function testValidatePort(int $port): void ]); it('accepts valid domain names', function (string $host) { - // ARRANGE & ACT & ASSERT - expect(fn () => $this->validator->testValidateHost($host))->not->toThrow(\InvalidArgumentException::class); + // ARRANGE + $this->validator->servers = mockServerRepository(true, ['servers' => []]); + + // ACT + $error = $this->validator->testValidateHost($host); + + // ASSERT + expect($error)->toBeNull(); })->with([ 'simple domain' => ['example.com'], 'subdomain' => ['server.example.com'], @@ -74,81 +151,95 @@ public function testValidatePort(int $port): void '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'); + it('rejects invalid hosts with error messages', function (string $host, string $expectedError) { + // ARRANGE + $this->validator->servers = mockServerRepository(true, ['servers' => []]); + + // ACT + $error = $this->validator->testValidateHost($host); + + // ASSERT + expect($error)->toContain($expectedError); })->with([ - 'empty string' => [''], - 'underscore' => ['server_name'], - 'spaces' => ['my server'], - 'special chars' => ['server!@#'], - 'double dots' => ['example..com'], + 'empty string' => ['', 'valid'], + 'underscore' => ['server_name', 'valid'], + 'spaces' => ['my server', 'valid'], + 'special chars' => ['server!@#', 'valid'], + 'double dots' => ['example..com', 'valid'], ]); - it('provides helpful error message for invalid hosts', function () { + it('rejects duplicate server hosts', function (string $host) { // 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'); - } - }); + $this->validator->servers = mockServerRepository(true, [ + 'servers' => [ + ['name' => 'existing-server', 'host' => $host, 'port' => 22, 'username' => 'root'], + ], + ]); + + // ACT + $error = $this->validator->testValidateHost($host); + + // ASSERT + expect($error)->toContain('already used by server') + ->and($error)->toContain('existing-server'); + })->with([ + 'IP address' => ['192.168.1.100'], + 'domain' => ['example.com'], + ]); // - // validatePort + // validatePortInput // ------------------------------------------------------------------------------- - it('accepts valid port numbers', function (int $port) { - // ARRANGE & ACT & ASSERT - expect(fn () => $this->validator->testValidatePort($port))->not->toThrow(\InvalidArgumentException::class); + it('accepts valid port numbers', function (string $portString) { + // ARRANGE & ACT + $error = $this->validator->testValidatePort($portString); + + // ASSERT + expect($error)->toBeNull(); })->with([ - 'SSH default' => [22], - 'HTTP' => [80], - 'HTTPS' => [443], - 'custom high' => [8080], - 'alternative SSH' => [2222], - 'minimum port' => [1], - 'maximum port' => [65535], + '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'); + it('rejects non-numeric port strings', function (string $portString) { + // ARRANGE & ACT + $error = $this->validator->testValidatePort($portString); + + // ASSERT + expect($error)->toContain('must be a number'); })->with([ - 'zero' => [0], - 'negative' => [-1], - 'large negative' => [-100], - 'too high' => [65536], - 'way too high' => [100000], + 'letters' => ['abc'], + 'empty' => [''], + 'special chars' => ['22!'], + 'floating point' => ['22.5'], ]); - 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'); - } - }); + it('rejects out of range port numbers', function (string $portString, string $expectedError) { + // ARRANGE & ACT + $error = $this->validator->testValidatePort($portString); + + // ASSERT + expect($error)->toContain($expectedError); + })->with([ + 'zero' => ['0', 'between 1 and 65535'], + 'too high' => ['65536', 'between 1 and 65535'], + 'way too high' => ['100000', 'between 1 and 65535'], + ]); + + it('rejects negative port numbers as non-numeric', function (string $portString) { + // ARRANGE & ACT + $error = $this->validator->testValidatePort($portString); + + // ASSERT + expect($error)->toContain('must be a number'); + })->with([ + 'negative' => ['-1'], + 'large negative' => ['-100'], + ]); });