From 85d9c75f8f3f9be1545834439ccb2ad2c3c8c739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 16 Oct 2025 13:27:17 +0300 Subject: [PATCH 01/17] feat(site): add site CRUD commands and traits Add SiteAddCommand, SiteDeleteCommand, and SiteListCommand with comprehensive site management functionality. Includes SiteHelpersTrait and SiteValidationTrait for reusable site operations and validation logic. Commands follow the established Server command patterns for consistency and maintainability. --- app/Console/Site/SiteAddCommand.php | 267 +++++++++++++++++++++++++ app/Console/Site/SiteDeleteCommand.php | 99 +++++++++ app/Console/Site/SiteListCommand.php | 56 ++++++ app/Traits/SiteHelpersTrait.php | 137 +++++++++++++ app/Traits/SiteValidationTrait.php | 110 ++++++++++ 5 files changed, 669 insertions(+) create mode 100644 app/Console/Site/SiteAddCommand.php create mode 100644 app/Console/Site/SiteDeleteCommand.php create mode 100644 app/Console/Site/SiteListCommand.php create mode 100644 app/Traits/SiteHelpersTrait.php create mode 100644 app/Traits/SiteValidationTrait.php diff --git a/app/Console/Site/SiteAddCommand.php b/app/Console/Site/SiteAddCommand.php new file mode 100644 index 00000000..902ef6be --- /dev/null +++ b/app/Console/Site/SiteAddCommand.php @@ -0,0 +1,267 @@ +addOption('domain', null, InputOption::VALUE_REQUIRED, 'Domain name') + ->addOption('type', null, InputOption::VALUE_REQUIRED, 'Site type: git or local') + ->addOption('repo', null, InputOption::VALUE_REQUIRED, 'Git repository URL (for git sites)') + ->addOption('branch', null, InputOption::VALUE_REQUIRED, 'Git branch name (for git sites)') + ->addOption('servers', null, InputOption::VALUE_REQUIRED, 'Comma-separated server names'); + } + + // + // Execution + // ------------------------------------------------------------------------------- + + protected function execute(InputInterface $input, OutputInterface $output): int + { + parent::execute($input, $output); + + $this->io->hr(); + + $this->io->h1('Add New Site'); + + // + // Check if there are any servers + + if (count($this->servers->all()) === 0) { + $this->io->warning('No servers available'); + $this->io->writeln([ + '', + 'You must add at least one server before adding a site.', + 'Run server:provision to provision your first server,', + 'or run server:add to add an existing server.', + '', + ]); + + return Command::FAILURE; + } + + // + // Gather site details + + /** @var string|null $domain */ + $domain = $this->io->getValidatedOptionOrPrompt( + 'domain', + fn ($validate) => $this->io->promptText( + label: 'Domain name:', + placeholder: 'example.com', + required: true, + validate: $validate + ), + fn ($value) => $this->validateDomainInput($value) + ); + + if ($domain === null) { + return Command::FAILURE; + } + + // + // Select site type + + /** @var string $siteType */ + $siteType = $this->io->getOptionOrPrompt( + 'type', + fn (): string => (string) $this->io->promptSelect( + label: 'Deploy from:', + options: ['git' => 'Git Repository', 'local' => 'Local files'], + default: 'git' + ) + ); + + $isLocal = $siteType === 'local'; + + // + // Gather git-specific details + + $repo = null; + $branch = null; + + if (!$isLocal) { + $defaultRepo = $this->detectGitRemote() ?? 'git@github.com:user/repo.git'; + + /** @var string $repo */ + $repo = $this->io->getOptionOrPrompt( + 'repo', + fn (): string => $this->io->promptText( + label: 'Git repository URL:', + placeholder: $defaultRepo, + default: $defaultRepo, + required: true + ) + ); + + $defaultBranch = $this->detectGitBranch() ?? 'main'; + + /** @var string|null $branch */ + $branch = $this->io->getValidatedOptionOrPrompt( + 'branch', + fn ($validate) => $this->io->promptText( + label: 'Git branch:', + placeholder: $defaultBranch, + default: $defaultBranch, + required: true, + validate: $validate + ), + fn ($value) => $this->validateBranchInput($value) + ); + + if ($branch === null) { + return Command::FAILURE; + } + } + + // + // Select servers + + $selectedServers = $this->selectServers(); + + // + // Validate selections + + try { + $this->validateServers($selectedServers); + } catch (\RuntimeException $e) { + $this->io->error($e->getMessage()); + + return Command::FAILURE; + } + + // + // Create DTO and display site info + + $site = new SiteDTO( + domain: $domain, + repo: $repo, + branch: $branch, + servers: $selectedServers + ); + + $this->io->hr(); + + $this->displaySiteDeets($site); + + // + // Save to repository + + try { + $this->sites->create($site); + } catch (\RuntimeException $e) { + $this->io->error('Failed to add site: ' . $e->getMessage()); + + return Command::FAILURE; + } + + $this->io->success('Site added successfully'); + $this->io->writeln(''); + + // + // Show command hint + + $hintOptions = [ + 'domain' => $domain, + 'type' => $siteType, + 'servers' => implode(',', $selectedServers), + ]; + + if (!$isLocal) { + $hintOptions['repo'] = $repo; + $hintOptions['branch'] = $branch; + } + + $this->io->showCommandHint('site:add', $hintOptions); + + return Command::SUCCESS; + } + + // + // Private Helpers + // ------------------------------------------------------------------------------- + + /** + * Detect git remote origin URL from current directory. + */ + private function detectGitRemote(): ?string + { + try { + $cwd = getcwd(); + if ($cwd === false) { + return null; + } + + $process = $this->proc->run( + ['git', 'config', '--get', 'remote.origin.url'], + $cwd, + 2.0 + ); + + if ($process->isSuccessful()) { + return trim($process->getOutput()); + } + + return null; + } catch (\Exception) { + return null; + } + } + + /** + * Detect current git branch name. + */ + private function detectGitBranch(): ?string + { + try { + $cwd = getcwd(); + if ($cwd === false) { + return null; + } + + $process = $this->proc->run( + ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], + $cwd, + 2.0 + ); + + if ($process->isSuccessful()) { + return trim($process->getOutput()); + } + + return null; + } catch (\Exception) { + return null; + } + } + +} diff --git a/app/Console/Site/SiteDeleteCommand.php b/app/Console/Site/SiteDeleteCommand.php new file mode 100644 index 00000000..269ea56c --- /dev/null +++ b/app/Console/Site/SiteDeleteCommand.php @@ -0,0 +1,99 @@ +addOption('site', null, InputOption::VALUE_REQUIRED, 'Site domain') + ->addOption('yes', 'y', InputOption::VALUE_NONE, 'Skip confirmation prompt'); + } + + // + // Execution + // ------------------------------------------------------------------------------- + + protected function execute(InputInterface $input, OutputInterface $output): int + { + parent::execute($input, $output); + + $this->io->hr(); + + $this->io->h1('Delete Site'); + + // + // Select site + + $selection = $this->selectSite(); + + if ($selection['site'] === null) { + return $selection['exit_code']; + } + + $site = $selection['site']; + $this->displaySiteDeets($site); + + // + // Confirm deletion + + $this->io->writeln(''); + + /** @var bool $confirmed */ + $confirmed = $this->io->getOptionOrPrompt( + 'yes', + fn (): bool => $this->io->promptConfirm( + label: 'Are you sure you want to delete this site?', + default: true + ) + ); + + if (!$confirmed) { + $this->io->warning('Cancelled deleting site'); + $this->io->writeln(''); + + return Command::SUCCESS; + } + + // + // Delete site + + $this->sites->delete($site->domain); + + $this->io->success("Site '{$site->domain}' deleted successfully"); + $this->io->writeln(''); + + // + // Show command hint + + $this->io->showCommandHint('site:delete', [ + 'site' => $site->domain, + 'yes' => $confirmed, + ]); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Site/SiteListCommand.php b/app/Console/Site/SiteListCommand.php new file mode 100644 index 00000000..4edd7a7e --- /dev/null +++ b/app/Console/Site/SiteListCommand.php @@ -0,0 +1,56 @@ +io->hr(); + + // + // Get all sites + + $allSites = $this->sites->all(); + if (count($allSites) === 0) { + $this->io->warning('No sites found in inventory'); + $this->io->writeln([ + '', + 'Use site:add to add a site', + '', + ]); + + return Command::SUCCESS; + } + + $this->io->h1('All Sites'); + + foreach ($allSites as $site) { + $this->displaySiteDeets($site); + } + + return Command::SUCCESS; + } + +} diff --git a/app/Traits/SiteHelpersTrait.php b/app/Traits/SiteHelpersTrait.php new file mode 100644 index 00000000..6942dcd7 --- /dev/null +++ b/app/Traits/SiteHelpersTrait.php @@ -0,0 +1,137 @@ +sites->all(); + if (count($allSites) === 0) { + $this->io->warning('No sites found in inventory'); + $this->io->writeln([ + '', + 'Use site:add to add a site', + '', + ]); + + return ['site' => null, 'exit_code' => Command::SUCCESS]; + } + + // + // Extract site domains and prompt for selection + + $siteDomains = array_map(fn (SiteDTO $site) => $site->domain, $allSites); + + $domain = (string) $this->io->getOptionOrPrompt( + $optionName, + fn () => $this->io->promptSelect( + label: $promptLabel, + options: $siteDomains, + ) + ); + + // + // Find site by domain + + $site = $this->sites->findByDomain($domain); + + if ($site === null) { + $this->io->error("Site '{$domain}' not found in inventory"); + + return ['site' => null, 'exit_code' => Command::FAILURE]; + } + + return ['site' => $site, 'exit_code' => Command::SUCCESS]; + } + + /** + * Multi-select servers from inventory. + * + * Supports both CLI option (comma-separated server names) and interactive multiselect prompt. + * + * @param string $optionName Option name to check for pre-provided values + * @return array Selected server names + */ + protected function selectServers(string $optionName = 'servers'): array + { + // + // Get all servers and extract names + + $allServers = $this->servers->all(); + $serverNames = array_map(fn (ServerDTO $server): string => $server->name, $allServers); + + // + // Get servers via option or prompt + + /** @var string|array $serversInput */ + $serversInput = $this->io->getOptionOrPrompt( + $optionName, + fn (): array => $this->io->promptMultiselect( + label: 'Select servers:', + options: $serverNames, + required: true + ) + ); + + // + // Parse input into array of server names + + if (is_string($serversInput)) { + // Parse comma-separated server names from CLI option + $selectedServers = array_map('trim', explode(',', $serversInput)); + } else { + // Already an array from interactive prompt + $selectedServers = $serversInput; + } + + // Ensure array values are strings with sequential integer keys + return array_values(array_filter(array_map('strval', $selectedServers))); + } + + /** + * Display site details. + */ + protected function displaySiteDeets(SiteDTO $site): void + { + $lines = [" Domain: {$site->domain}"]; + + if ($site->isLocal()) { + $lines[] = " Type: Local"; + } else { + $lines[] = " Type: Git"; + $lines[] = " Repo: {$site->repo}"; + $lines[] = " Branch: {$site->branch}"; + } + + $lines[] = " Servers: ".implode(', ', $site->servers).''; + $lines[] = ' '; + + $this->io->writeln($lines); + } +} diff --git a/app/Traits/SiteValidationTrait.php b/app/Traits/SiteValidationTrait.php new file mode 100644 index 00000000..51114b36 --- /dev/null +++ b/app/Traits/SiteValidationTrait.php @@ -0,0 +1,110 @@ +sites->findByDomain($domain); + if ($existing !== null) { + return "Domain '{$domain}' already exists in inventory"; + } + + return null; + } + + /** + * Validate branch name is not empty. + * + * @return string|null Error message if invalid, null if valid + */ + protected function validateBranchInput(mixed $branch): ?string + { + if (!is_string($branch)) { + return 'Branch name must be a string'; + } + + if (trim($branch) === '') { + return 'Branch name cannot be empty'; + } + + return null; + } + + /** + * Validate git repository is accessible. + * + * @throws \RuntimeException When repository is not accessible + */ + protected function validateGitRepo(string $repo): void + { + try { + $cwd = getcwd(); + if ($cwd === false) { + throw new \RuntimeException('Could not determine current working directory'); + } + + $process = $this->proc->run( + ['git', 'ls-remote', '--exit-code', $repo], + $cwd, + 10.0 + ); + + if (!$process->isSuccessful()) { + throw new \RuntimeException( + "Cannot access git repository '{$repo}'.\n". + 'Error: '.$process->getErrorOutput() + ); + } + } catch (\Exception $e) { + throw new \RuntimeException( + "Failed to validate git repository '{$repo}'.\n". + 'Error: '.$e->getMessage() + ); + } + } + + /** + * Validate all servers exist in inventory. + * + * @param array $serverNames + * @throws \RuntimeException When any server is not found + */ + protected function validateServers(array $serverNames): void + { + if (count($serverNames) === 0) { + throw new \RuntimeException('At least one server must be selected'); + } + + foreach ($serverNames as $serverName) { + $server = $this->servers->findByName($serverName); + if ($server === null) { + throw new \RuntimeException("Server '{$serverName}' not found in inventory"); + } + } + } +} From bcc0323fb75de48cb300ac811e1d2bad1e242d15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 16 Oct 2025 13:27:19 +0300 Subject: [PATCH 02/17] feat(site): enhance SiteDTO and SiteRepository Update SiteDTO with improved type hints and properties. Enhance SiteRepository with additional query and persistence methods to support the new site management command functionality. --- app/DTOs/SiteDTO.php | 16 +++++++--- app/Repositories/SiteRepository.php | 45 +++++++++++++++++++++++------ 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/app/DTOs/SiteDTO.php b/app/DTOs/SiteDTO.php index d2d34ccd..dc9bd797 100644 --- a/app/DTOs/SiteDTO.php +++ b/app/DTOs/SiteDTO.php @@ -10,15 +10,23 @@ * Create a SiteDTO containing the site's domain, repository, branch, and associated servers. * * @param string $domain The site's domain name (e.g. example.com). - * @param string $repo The repository URL or identifier for the site. - * @param string $branch The repository branch to deploy (e.g. main). + * @param string|null $repo The repository URL for git sites, null for local sites. + * @param string|null $branch The repository branch for git sites (e.g. main), null for local sites. * @param array $servers Ordered list of server hostnames or addresses associated with the site. */ public function __construct( public string $domain, - public string $repo, - public string $branch, + public ?string $repo, + public ?string $branch, public array $servers, ) { } + + /** + * Check if this is a local site (no git repository). + */ + public function isLocal(): bool + { + return $this->repo === null; + } } diff --git a/app/Repositories/SiteRepository.php b/app/Repositories/SiteRepository.php index cab5914b..eba0e79a 100644 --- a/app/Repositories/SiteRepository.php +++ b/app/Repositories/SiteRepository.php @@ -102,6 +102,27 @@ public function all(): array return $result; } + /** + * Retrieve all sites that belong to a specific server. + * + * @param string $serverName Server name to filter by + * @return array Sites that include the server + */ + public function findByServer(string $serverName): array + { + $this->assertInventoryLoaded(); + + $filtered = []; + foreach ($this->sites as $siteData) { + $site = $this->hydrateSiteDTO($siteData); + if (in_array($serverName, $site->servers, true)) { + $filtered[] = $site; + } + } + + return $filtered; + } + /** * Remove the site with the given domain from the stored inventory. * @@ -146,35 +167,41 @@ private function assertInventoryLoaded(): void * Serialize a SiteDTO into an associative array suitable for inventory storage. * * @param SiteDTO $site The site DTO to serialize. - * @return array Associative array with keys `domain`, `repo`, `branch`, and `servers`. + * @return array Associative array with keys `domain`, `servers`, and optionally `repo`/`branch` for git sites. */ private function dehydrateSiteDTO(SiteDTO $site): array { - return [ + $data = [ 'domain' => $site->domain, - 'repo' => $site->repo, - 'branch' => $site->branch, 'servers' => $site->servers, ]; + + // Only include repo/branch for git-based sites + if (!$site->isLocal()) { + $data['repo'] = $site->repo; + $data['branch'] = $site->branch; + } + + return $data; } /** * Create a SiteDTO from raw inventory data. * * @param array $data Raw associative array from inventory. - * @return SiteDTO A SiteDTO where `domain`, `repo`, and `branch` are strings (empty string if missing or not a string) and `servers` is an array of strings (empty array if missing or invalid). + * @return SiteDTO A SiteDTO where `domain` is a string (empty if missing), `repo` and `branch` are nullable strings (null for local sites), and `servers` is an array of strings. */ private function hydrateSiteDTO(array $data): SiteDTO { $domain = $data['domain'] ?? ''; - $repo = $data['repo'] ?? ''; - $branch = $data['branch'] ?? ''; + $repo = $data['repo'] ?? null; + $branch = $data['branch'] ?? null; $servers = $data['servers'] ?? []; return new SiteDTO( domain: is_string($domain) ? $domain : '', - repo: is_string($repo) ? $repo : '', - branch: is_string($branch) ? $branch : '', + repo: is_string($repo) ? $repo : null, + branch: is_string($branch) ? $branch : null, servers: is_array($servers) ? array_values(array_filter($servers, 'is_string')) : [], ); } From 03a2b482d42aff83b580a0b53126646bc3b73155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 16 Oct 2025 13:27:23 +0300 Subject: [PATCH 03/17] refactor(app): register site commands and update fixtures Register new Site commands in SymfonyApp. Update TestConsoleCommand fixture to support site command testing patterns. --- app/SymfonyApp.php | 6 ++++++ tests/Fixtures/TestConsoleCommand.php | 3 +++ 2 files changed, 9 insertions(+) diff --git a/app/SymfonyApp.php b/app/SymfonyApp.php index 35f76279..90bf5977 100644 --- a/app/SymfonyApp.php +++ b/app/SymfonyApp.php @@ -8,6 +8,9 @@ use Bigpixelrocket\DeployerPHP\Console\Server\ServerAddCommand; use Bigpixelrocket\DeployerPHP\Console\Server\ServerDeleteCommand; use Bigpixelrocket\DeployerPHP\Console\Server\ServerListCommand; +use Bigpixelrocket\DeployerPHP\Console\Site\SiteAddCommand; +use Bigpixelrocket\DeployerPHP\Console\Site\SiteDeleteCommand; +use Bigpixelrocket\DeployerPHP\Console\Site\SiteListCommand; use Bigpixelrocket\DeployerPHP\Services\VersionService; use Symfony\Component\Console\Application as SymfonyApplication; use Symfony\Component\Console\Command\Command; @@ -120,6 +123,9 @@ private function registerCommands(): void ServerAddCommand::class, ServerDeleteCommand::class, ServerListCommand::class, + SiteAddCommand::class, + SiteDeleteCommand::class, + SiteListCommand::class, ]; foreach ($commands as $command) { diff --git a/tests/Fixtures/TestConsoleCommand.php b/tests/Fixtures/TestConsoleCommand.php index 4563ad4d..fe20345a 100644 --- a/tests/Fixtures/TestConsoleCommand.php +++ b/tests/Fixtures/TestConsoleCommand.php @@ -14,6 +14,7 @@ use Bigpixelrocket\DeployerPHP\Services\ProcessService; use Bigpixelrocket\DeployerPHP\Services\SSHService; use Bigpixelrocket\DeployerPHP\Traits\ServerHelpersTrait; +use Bigpixelrocket\DeployerPHP\Traits\SiteHelpersTrait; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -27,6 +28,7 @@ class TestConsoleCommand extends BaseCommand { use ServerHelpersTrait; + use SiteHelpersTrait; private string $methodToTest = ''; private array $testArgs = []; @@ -87,6 +89,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'writeln' => $this->io->writeln(...$this->testArgs), 'showCommandHint' => $this->io->showCommandHint(...$this->testArgs), 'displayServerDeets' => $this->displayServerDeets(...$this->testArgs), + 'displaySiteDeets' => $this->displaySiteDeets(...$this->testArgs), 'getOptionOrPrompt' => $this->testGetOptionOrPrompt(), 'getOptionOrPromptEmpty' => $this->testGetOptionOrPromptEmpty(), 'getOptionOrPromptBoolean' => $this->testGetOptionOrPromptBoolean(), From 79c08c979b03a197b945893a8b9a4f4dde748e87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 16 Oct 2025 13:27:25 +0300 Subject: [PATCH 04/17] refactor(server): standardize command implementation Align ServerAddCommand, ServerDeleteCommand, and ServerListCommand with improved patterns and consistency standards used across the codebase. --- app/Console/Server/ServerAddCommand.php | 90 +--------------------- app/Console/Server/ServerDeleteCommand.php | 19 +++++ app/Console/Server/ServerListCommand.php | 25 +++++- 3 files changed, 45 insertions(+), 89 deletions(-) diff --git a/app/Console/Server/ServerAddCommand.php b/app/Console/Server/ServerAddCommand.php index f5156430..18eda33c 100644 --- a/app/Console/Server/ServerAddCommand.php +++ b/app/Console/Server/ServerAddCommand.php @@ -17,7 +17,7 @@ /** * Add and register a new server to the inventory. * - * Prompts for server details and verifies SSH connectivity before saving. + * Prompts for server details and saves to inventory. */ #[AsCommand(name: 'server:add', description: 'Add a new server to the inventory')] class ServerAddCommand extends BaseCommand @@ -38,9 +38,7 @@ protected function configure(): void ->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'); + ->addOption('private-key-path', null, InputOption::VALUE_REQUIRED, 'SSH private key path'); } // @@ -146,46 +144,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->displayServerDeets($server); - // - // Verify connectivity - - /** @var bool $skipCheck */ - $skipCheck = $this->io->getOptionOrPrompt( - 'skip', - fn (): bool => !$this->io->promptConfirm( - label: 'Test SSH connection before saving?', - default: true - ) - ); - - if ($skipCheck) { - $this->io->warning('Skipping SSH connection check'); - $this->io->writeln(''); - } else { - if (!$this->testConnection($server)) { - return Command::FAILURE; - } - } - - // - // Confirm creation - - /** @var bool $confirmed */ - $confirmed = $this->io->getOptionOrPrompt( - 'yes', - fn (): bool => $this->io->promptConfirm( - label: 'Save this server to inventory?', - default: true - ) - ); - - if (!$confirmed) { - $this->io->warning('Cancelled adding server'); - $this->io->writeln(''); - - return Command::SUCCESS; - } - // // Save to repository @@ -209,53 +167,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int '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->io->promptSpin( - callback: fn () => $this->ssh->assertCanConnect( - $server->host, - $server->port, - $server->username, - $server->privateKeyPath - ), - message: 'Connecting to server...' - ); - - $this->io->success('SSH connection successful'); - - return true; - } catch (\RuntimeException $e) { - $this->io->error($e->getMessage()); - - $this->io->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 index a3a6ff24..409ecf01 100644 --- a/app/Console/Server/ServerDeleteCommand.php +++ b/app/Console/Server/ServerDeleteCommand.php @@ -57,9 +57,28 @@ protected function execute(InputInterface $input, OutputInterface $output): int $server = $selection['server']; $this->displayServerDeets($server); + // Get sites for this server + $serverSites = $this->sites->findByServer($server->name); + + if (count($serverSites) > 0) { + $this->io->writeln([' Sites:']); + foreach ($serverSites as $site) { + $this->io->writeln([" • {$site->domain}"]); + } + + $this->io->writeln(''); + + $this->io->error("Cannot delete server '{$server->name}' because it has one or more sites."); + + return Command::FAILURE; + + } + // // Confirm deletion + $this->io->writeln(''); + /** @var bool $confirmed */ $confirmed = $this->io->getOptionOrPrompt( 'yes', diff --git a/app/Console/Server/ServerListCommand.php b/app/Console/Server/ServerListCommand.php index dde14638..8930991e 100644 --- a/app/Console/Server/ServerListCommand.php +++ b/app/Console/Server/ServerListCommand.php @@ -6,6 +6,7 @@ use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; use Bigpixelrocket\DeployerPHP\Traits\ServerHelpersTrait; +use Bigpixelrocket\DeployerPHP\Traits\SiteHelpersTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -18,6 +19,7 @@ class ServerListCommand extends BaseCommand { use ServerHelpersTrait; + use SiteHelpersTrait; // // Execution @@ -44,10 +46,31 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } + // + // Display servers with their sites + $this->io->h1('All Servers'); - foreach ($allServers as $server) { + foreach ($allServers as $count => $server) { $this->displayServerDeets($server); + + // Get sites for this server + $serverSites = $this->sites->findByServer($server->name); + + if (count($serverSites) > 0) { + $this->io->writeln([' Sites:']); + foreach ($serverSites as $site) { + $this->io->writeln([" • {$site->domain}"]); + } + $this->io->writeln(''); + } + + if ($count < count($allServers) - 1) { + $this->io->writeln([ + ' ───', + '', + ]); + } } return Command::SUCCESS; From 0e8d44965920ff0adefd4361c322bf6a18913ee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 16 Oct 2025 13:27:29 +0300 Subject: [PATCH 05/17] test(site): add comprehensive site command test coverage Add full integration and unit test coverage for Site commands: - SiteAddCommandTest, SiteDeleteCommandTest, SiteListCommandTest - SiteDTOTest, SiteRepositoryTest updates - SiteHelpersTraitTest, SiteValidationTraitTest --- .../Console/Site/SiteAddCommandTest.php | 244 ++++++++++++++++++ .../Console/Site/SiteDeleteCommandTest.php | 106 ++++++++ .../Console/Site/SiteListCommandTest.php | 133 ++++++++++ tests/Unit/DTOs/SiteDTOTest.php | 22 +- .../Unit/Repositories/SiteRepositoryTest.php | 127 +++++++-- tests/Unit/Traits/SiteHelpersTraitTest.php | 82 ++++++ tests/Unit/Traits/SiteValidationTraitTest.php | 242 +++++++++++++++++ 7 files changed, 938 insertions(+), 18 deletions(-) create mode 100644 tests/Integration/Console/Site/SiteAddCommandTest.php create mode 100644 tests/Integration/Console/Site/SiteDeleteCommandTest.php create mode 100644 tests/Integration/Console/Site/SiteListCommandTest.php create mode 100644 tests/Unit/Traits/SiteHelpersTraitTest.php create mode 100644 tests/Unit/Traits/SiteValidationTraitTest.php diff --git a/tests/Integration/Console/Site/SiteAddCommandTest.php b/tests/Integration/Console/Site/SiteAddCommandTest.php new file mode 100644 index 00000000..f773a47f --- /dev/null +++ b/tests/Integration/Console/Site/SiteAddCommandTest.php @@ -0,0 +1,244 @@ + array_map( + fn (ServerDTO $server) => [ + 'name' => $server->name, + 'host' => $server->host, + 'port' => $server->port, + 'username' => $server->username, + 'privateKeyPath' => $server->privateKeyPath, + ], + $existingServers + ), + 'sites' => $existingSites, + ]; + + $container = mockCommandContainer(inventoryData: $inventoryData); + $command = $container->build(SiteAddCommand::class); + return new CommandTester($command); +} + +// +// Integration tests +// ------------------------------------------------------------------------------- + +describe('SiteAddCommand', function () { + // + // Success Scenarios + // ------------------------------------------------------------------------------- + + it('adds git site with all options provided non-interactively', function () { + // ARRANGE + $existingServers = [ + new ServerDTO('web1', '192.168.1.1', 22, 'root', null), + ]; + $tester = createSiteAddCommandTester($existingServers); + + // ACT + $exitCode = $tester->execute([ + '--domain' => 'example.com', + '--type' => 'git', + '--repo' => 'git@github.com:user/repo.git', + '--branch' => 'main', + '--servers' => 'web1', + ]); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toContain('✓') + ->and($output)->toContain('Site added successfully') + ->and($output)->toContain('Run non-interactively:') + ->and($output)->toContain('site:add') + ->and($output)->toContain('example.com') + ->and($output)->toContain('git@github.com:user/repo.git'); + }); + + it('adds local site with minimal options', function () { + // ARRANGE + $existingServers = [ + new ServerDTO('web1', '192.168.1.1', 22, 'root', null), + ]; + $tester = createSiteAddCommandTester($existingServers); + + // ACT + $exitCode = $tester->execute([ + '--domain' => 'local.test', + '--type' => 'local', + '--servers' => 'web1', + ]); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toContain('✓') + ->and($output)->toContain('Site added successfully') + ->and($output)->toContain('local.test') + ->and($output)->toContain('Local'); + }); + + it('adds site with multiple servers', function () { + // ARRANGE + $existingServers = [ + new ServerDTO('web1', '192.168.1.1', 22, 'root', null), + new ServerDTO('web2', '192.168.1.2', 22, 'root', null), + ]; + $tester = createSiteAddCommandTester($existingServers); + + // ACT + $exitCode = $tester->execute([ + '--domain' => 'multi-server.com', + '--type' => 'git', + '--repo' => 'git@github.com:user/app.git', + '--branch' => 'production', + '--servers' => 'web1,web2', + ]); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toContain('✓') + ->and($output)->toContain('Site added successfully') + ->and($output)->toContain('multi-server.com') + ->and($output)->toContain('web1, web2'); + }); + + // + // Error Scenarios + // ------------------------------------------------------------------------------- + + it('fails when no servers are available', function () { + // ARRANGE + $tester = createSiteAddCommandTester([]); + + // ACT + $exitCode = $tester->execute([]); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::FAILURE) + ->and($output)->toContain('⚠') + ->and($output)->toContain('No servers available') + ->and($output)->toContain('server:add'); + }); + + it('rejects invalid domain with helpful error message', function (string $invalidDomain) { + // ARRANGE + $existingServers = [ + new ServerDTO('web1', '192.168.1.1', 22, 'root', null), + ]; + $tester = createSiteAddCommandTester($existingServers); + + // ACT + $exitCode = $tester->execute([ + '--domain' => $invalidDomain, + '--type' => 'local', + '--servers' => 'web1', + ]); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::FAILURE) + ->and($output)->toContain('✗') + ->and($output)->toContain('valid'); + })->with([ + 'invalid chars' => ['example!@#.com'], + 'spaces' => ['example .com'], + 'empty' => [''], + ]); + + it('rejects invalid branch with helpful error message', function (string $invalidBranch) { + // ARRANGE + $existingServers = [ + new ServerDTO('web1', '192.168.1.1', 22, 'root', null), + ]; + $tester = createSiteAddCommandTester($existingServers); + + // ACT + $exitCode = $tester->execute([ + '--domain' => 'test.com', + '--type' => 'git', + '--repo' => 'git@github.com:user/repo.git', + '--branch' => $invalidBranch, + '--servers' => 'web1', + ]); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::FAILURE) + ->and($output)->toContain('✗') + ->and($output)->toContain('Branch'); + })->with([ + 'empty' => [''], + 'whitespace only' => [' '], + ]); + + it('prevents duplicate site domains', function () { + // ARRANGE + $existingServers = [ + new ServerDTO('web1', '192.168.1.1', 22, 'root', null), + ]; + $existingSites = [ + [ + 'domain' => 'duplicate.com', + 'repo' => null, + 'branch' => null, + 'servers' => ['web1'], + ], + ]; + $tester = createSiteAddCommandTester($existingServers, $existingSites); + + // ACT + $exitCode = $tester->execute([ + '--domain' => 'duplicate.com', + '--type' => 'local', + '--servers' => 'web1', + ]); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::FAILURE) + ->and($output)->toContain('✗') + ->and($output)->toContain('already exists') + ->and($output)->toContain('duplicate.com'); + }); + + it('rejects non-existent server names', function () { + // ARRANGE + $existingServers = [ + new ServerDTO('web1', '192.168.1.1', 22, 'root', null), + ]; + $tester = createSiteAddCommandTester($existingServers); + + // ACT + $exitCode = $tester->execute([ + '--domain' => 'test.com', + '--type' => 'local', + '--servers' => 'non-existent', + ]); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::FAILURE) + ->and($output)->toContain('✗') + ->and($output)->toContain('non-existent') + ->and($output)->toMatch('/not found|does not exist/i'); + }); +}); diff --git a/tests/Integration/Console/Site/SiteDeleteCommandTest.php b/tests/Integration/Console/Site/SiteDeleteCommandTest.php new file mode 100644 index 00000000..155eee05 --- /dev/null +++ b/tests/Integration/Console/Site/SiteDeleteCommandTest.php @@ -0,0 +1,106 @@ + array_map( + fn (SiteDTO $site) => [ + 'domain' => $site->domain, + 'repo' => $site->repo, + 'branch' => $site->branch, + 'servers' => $site->servers, + ], + $existingSites + ), + ]; + + $container = mockCommandContainer(inventoryData: $inventoryData); + $command = $container->build(SiteDeleteCommand::class); + return new CommandTester($command); +} + +// +// Integration tests +// ------------------------------------------------------------------------------- + +describe('SiteDeleteCommand', function () { + // + // Success Scenarios + // ------------------------------------------------------------------------------- + + it('deletes site with site option non-interactively', function () { + // ARRANGE + $existingSites = [ + new SiteDTO('example.com', 'git@github.com:user/repo.git', 'main', ['web1']), + new SiteDTO('app.example.com', null, null, ['web2']), + ]; + $tester = createSiteDeleteCommandTester($existingSites); + + // ACT + $exitCode = $tester->execute([ + '--site' => 'example.com', + '--yes' => true, + ]); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toContain('✓') + ->and($output)->toContain("Site 'example.com' deleted successfully") + ->and($output)->toContain('Run non-interactively:') + ->and($output)->toContain('site:delete'); + }); + + // + // Error Scenarios + // ------------------------------------------------------------------------------- + + it('fails when deleting non-existent site', function () { + // ARRANGE + $existingSites = [ + new SiteDTO('existing.com', null, null, ['web1']), + ]; + $tester = createSiteDeleteCommandTester($existingSites); + + // ACT + $exitCode = $tester->execute([ + '--site' => 'non-existent.com', + '--yes' => true, + ]); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::FAILURE) + ->and($output)->toContain('✗') + ->and($output)->toContain("Site 'non-existent.com' not found"); + }); + + it('handles empty inventory gracefully', function () { + // ARRANGE + $tester = createSiteDeleteCommandTester([]); + + // ACT + $exitCode = $tester->execute([]); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toContain('⚠') + ->and($output)->toContain('No sites found in inventory') + ->and($output)->toContain('site:add'); + }); +}); diff --git a/tests/Integration/Console/Site/SiteListCommandTest.php b/tests/Integration/Console/Site/SiteListCommandTest.php new file mode 100644 index 00000000..c5cf78d7 --- /dev/null +++ b/tests/Integration/Console/Site/SiteListCommandTest.php @@ -0,0 +1,133 @@ + $existingSites + */ +function createSiteListCommandTester(array $existingSites = []): CommandTester +{ + // Build inventory data with sites + $inventoryData = [ + 'sites' => array_map( + fn (SiteDTO $site) => [ + 'domain' => $site->domain, + 'repo' => $site->repo, + 'branch' => $site->branch, + 'servers' => $site->servers, + ], + $existingSites + ), + ]; + + $container = mockCommandContainer(inventoryData: $inventoryData); + $command = $container->build(SiteListCommand::class); + return new CommandTester($command); +} + +// +// Integration tests +// ------------------------------------------------------------------------------- + +describe('SiteListCommand', function () { + // + // Success Scenarios + // ------------------------------------------------------------------------------- + + it('lists multiple sites with full details', function () { + // ARRANGE + $existingSites = [ + new SiteDTO('example.com', 'git@github.com:user/repo.git', 'main', ['web1']), + new SiteDTO('app.example.com', 'git@github.com:user/app.git', 'develop', ['web2']), + new SiteDTO('local.test', null, null, ['web1']), + ]; + $tester = createSiteListCommandTester($existingSites); + + // ACT + $exitCode = $tester->execute([]); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toContain('▸') + ->and($output)->toContain('All Sites') + ->and($output)->toContain('example.com') + ->and($output)->toContain('git@github.com:user/repo.git') + ->and($output)->toContain('main') + ->and($output)->toContain('app.example.com') + ->and($output)->toContain('develop') + ->and($output)->toContain('local.test') + ->and($output)->toContain('Local'); + }); + + it('lists single site with complete details', function () { + // ARRANGE + $existingSites = [ + new SiteDTO('production.com', 'git@github.com:company/prod.git', 'production', ['web1', 'web2']), + ]; + $tester = createSiteListCommandTester($existingSites); + + // ACT + $exitCode = $tester->execute([]); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toContain('All Sites') + ->and($output)->toContain('production.com') + ->and($output)->toContain('git@github.com:company/prod.git') + ->and($output)->toContain('production') + ->and($output)->toContain('web1, web2'); + }); + + // + // Edge Cases + // ------------------------------------------------------------------------------- + + it('handles empty inventory gracefully', function () { + // ARRANGE + $tester = createSiteListCommandTester([]); + + // ACT + $exitCode = $tester->execute([]); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toContain('⚠') + ->and($output)->toContain('No sites found in inventory') + ->and($output)->toContain('site:add') + ->and($output)->not->toContain('All Sites'); + }); + + it('displays server count correctly', function (array $servers, string $expectedOutput) { + // ARRANGE + $existingSites = [ + new SiteDTO('test.com', null, null, $servers), + ]; + $tester = createSiteListCommandTester($existingSites); + + // ACT + $exitCode = $tester->execute([]); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toContain('Servers:') + ->and($output)->toContain($expectedOutput); + })->with([ + 'single server' => [['web1'], 'web1'], + 'multiple servers' => [['web1', 'web2', 'web3'], 'web1, web2, web3'], + ]); +}); diff --git a/tests/Unit/DTOs/SiteDTOTest.php b/tests/Unit/DTOs/SiteDTOTest.php index cdcd2ed1..156565ed 100644 --- a/tests/Unit/DTOs/SiteDTOTest.php +++ b/tests/Unit/DTOs/SiteDTOTest.php @@ -5,7 +5,7 @@ use Bigpixelrocket\DeployerPHP\DTOs\SiteDTO; describe('SiteDTO', function () { - it('creates site with all properties', function () { + it('creates git site with all properties', function () { // ARRANGE & ACT $site = new SiteDTO( domain: 'example.com', @@ -18,6 +18,24 @@ expect($site->domain)->toBe('example.com') ->and($site->repo)->toBe('git@github.com:user/repo.git') ->and($site->branch)->toBe('main') - ->and($site->servers)->toBe(['production-web', 'staging-web']); + ->and($site->servers)->toBe(['production-web', 'staging-web']) + ->and($site->isLocal())->toBeFalse(); + }); + + it('creates local site without repo and branch', function () { + // ARRANGE & ACT + $site = new SiteDTO( + domain: 'local.dev', + repo: null, + branch: null, + servers: ['dev-web'] + ); + + // ASSERT + expect($site->domain)->toBe('local.dev') + ->and($site->repo)->toBeNull() + ->and($site->branch)->toBeNull() + ->and($site->servers)->toBe(['dev-web']) + ->and($site->isLocal())->toBeTrue(); }); }); diff --git a/tests/Unit/Repositories/SiteRepositoryTest.php b/tests/Unit/Repositories/SiteRepositoryTest.php index 8910004d..e19096bf 100644 --- a/tests/Unit/Repositories/SiteRepositoryTest.php +++ b/tests/Unit/Repositories/SiteRepositoryTest.php @@ -33,19 +33,29 @@ $repository->loadInventory($inventory); // ACT & ASSERT - Create - $site1 = new SiteDTO('example.com', 'git@github.com:user/repo.git', 'main', ['web1', 'web2']); - $site2 = new SiteDTO('test.com', '', '', []); + $gitSite = new SiteDTO('example.com', 'git@github.com:user/repo.git', 'main', ['web1', 'web2']); + $localSite = new SiteDTO('local.dev', null, null, ['dev1']); - $repository->create($site1); - $repository->create($site2); + $repository->create($gitSite); + $repository->create($localSite); - // ASSERT - Find by domain + // ASSERT - Find git site by domain $found = $repository->findByDomain('example.com'); expect($found)->not->toBeNull() ->and($found->domain)->toBe('example.com') ->and($found->repo)->toBe('git@github.com:user/repo.git') ->and($found->branch)->toBe('main') - ->and($found->servers)->toBe(['web1', 'web2']); + ->and($found->servers)->toBe(['web1', 'web2']) + ->and($found->isLocal())->toBeFalse(); + + // ASSERT - Find local site by domain + $foundLocal = $repository->findByDomain('local.dev'); + expect($foundLocal)->not->toBeNull() + ->and($foundLocal->domain)->toBe('local.dev') + ->and($foundLocal->repo)->toBeNull() + ->and($foundLocal->branch)->toBeNull() + ->and($foundLocal->servers)->toBe(['dev1']) + ->and($foundLocal->isLocal())->toBeTrue(); // ASSERT - Find returns null for missing expect($repository->findByDomain('nonexistent.com'))->toBeNull(); @@ -54,7 +64,7 @@ $all = $repository->all(); expect($all)->toHaveCount(2) ->and($all[0]->domain)->toBe('example.com') - ->and($all[1]->domain)->toBe('test.com'); + ->and($all[1]->domain)->toBe('local.dev'); // ACT & ASSERT - Delete $repository->delete('example.com'); @@ -80,11 +90,73 @@ ->toThrow(\RuntimeException::class, "Site 'existing.com' already exists"); }); + // + // Server Filtering + // ------------------------------------------------------------------------------- + + it('finds sites by server name', function () { + // ARRANGE + $inventory = mockInventoryService(true, ['sites' => [ + ['domain' => 'app1.com', 'repo' => 'git@github.com:user/app1.git', 'branch' => 'main', 'servers' => ['web1']], + ['domain' => 'app2.com', 'repo' => 'git@github.com:user/app2.git', 'branch' => 'main', 'servers' => ['web2']], + ['domain' => 'shared.com', 'repo' => 'git@github.com:user/shared.git', 'branch' => 'dev', 'servers' => ['web1', 'web2']], + ['domain' => 'local.dev', 'servers' => ['web1']], + ]]); + $inventory->loadInventoryFile(); + $repository = new SiteRepository(); + $repository->loadInventory($inventory); + + // ACT + $web1Sites = $repository->findByServer('web1'); + $web2Sites = $repository->findByServer('web2'); + + // ASSERT + expect($web1Sites)->toHaveCount(3) + ->and($web1Sites[0]->domain)->toBe('app1.com') + ->and($web1Sites[1]->domain)->toBe('shared.com') + ->and($web1Sites[2]->domain)->toBe('local.dev') + ->and($web2Sites)->toHaveCount(2) + ->and($web2Sites[0]->domain)->toBe('app2.com') + ->and($web2Sites[1]->domain)->toBe('shared.com'); + }); + + it('returns empty array when server has no sites', function () { + // ARRANGE + $inventory = mockInventoryService(true, ['sites' => [ + ['domain' => 'app1.com', 'repo' => 'git@github.com:user/app1.git', 'branch' => 'main', 'servers' => ['web1']], + ]]); + $inventory->loadInventoryFile(); + $repository = new SiteRepository(); + $repository->loadInventory($inventory); + + // ACT + $result = $repository->findByServer('web2'); + + // ASSERT + expect($result)->toBeArray()->toBeEmpty(); + }); + + it('returns empty array when filtering with nonexistent server', function () { + // ARRANGE + $inventory = mockInventoryService(true, ['sites' => [ + ['domain' => 'app1.com', 'repo' => 'git@github.com:user/app1.git', 'branch' => 'main', 'servers' => ['web1']], + ]]); + $inventory->loadInventoryFile(); + $repository = new SiteRepository(); + $repository->loadInventory($inventory); + + // ACT + $result = $repository->findByServer('nonexistent'); + + // ASSERT + expect($result)->toBeArray()->toBeEmpty(); + }); + // // Data Hydration Robustness // ------------------------------------------------------------------------------- - it('handles malformed inventory data gracefully', function (array $rawData, string $expectedDomain, string $expectedRepo, string $expectedBranch, array $expectedServers) { + it('handles malformed inventory data gracefully', function (array $rawData, string $expectedDomain, ?string $expectedRepo, ?string $expectedBranch, array $expectedServers) { // ARRANGE $inventory = mockInventoryService(true, ['sites' => [$rawData]]); $inventory->loadInventoryFile(); @@ -96,27 +168,50 @@ // ASSERT expect($sites)->toHaveCount(1) - ->and($sites[0]->domain)->toBe($expectedDomain) - ->and($sites[0]->repo)->toBe($expectedRepo) - ->and($sites[0]->branch)->toBe($expectedBranch) - ->and($sites[0]->servers)->toBe($expectedServers); + ->and($sites[0]->domain)->toBe($expectedDomain); + + if ($expectedRepo === null) { + expect($sites[0]->repo)->toBeNull(); + } else { + expect($sites[0]->repo)->toBe($expectedRepo); + } + + if ($expectedBranch === null) { + expect($sites[0]->branch)->toBeNull(); + } else { + expect($sites[0]->branch)->toBe($expectedBranch); + } + + expect($sites[0]->servers)->toBe($expectedServers); })->with([ 'missing domain' => [ ['repo' => 'git@github.com:user/repo.git', 'branch' => 'main', 'servers' => []], '', 'git@github.com:user/repo.git', 'main', [], ], - 'missing repo' => [ + 'missing repo (local site)' => [ ['domain' => 'example.com', 'branch' => 'main', 'servers' => []], - 'example.com', '', 'main', [], + 'example.com', null, 'main', [], ], - 'missing branch' => [ + 'missing branch (local site)' => [ ['domain' => 'example.com', 'repo' => 'git@github.com:user/repo.git', 'servers' => []], - 'example.com', 'git@github.com:user/repo.git', '', [], + 'example.com', 'git@github.com:user/repo.git', null, [], + ], + 'missing both repo and branch (local site)' => [ + ['domain' => 'local.dev', 'servers' => ['dev1']], + 'local.dev', null, null, ['dev1'], ], 'wrong domain type' => [ ['domain' => 12345, 'repo' => 'git@github.com:user/repo.git', 'branch' => 'main'], '', 'git@github.com:user/repo.git', 'main', [], ], + 'wrong repo type' => [ + ['domain' => 'example.com', 'repo' => 12345, 'branch' => 'main', 'servers' => []], + 'example.com', null, 'main', [], + ], + 'wrong branch type' => [ + ['domain' => 'example.com', 'repo' => 'git@github.com:user/repo.git', 'branch' => 12345, 'servers' => []], + 'example.com', 'git@github.com:user/repo.git', null, [], + ], 'wrong servers type' => [ ['domain' => 'example.com', 'repo' => 'git@github.com:user/repo.git', 'branch' => 'main', 'servers' => 'not-array'], 'example.com', 'git@github.com:user/repo.git', 'main', [], diff --git a/tests/Unit/Traits/SiteHelpersTraitTest.php b/tests/Unit/Traits/SiteHelpersTraitTest.php new file mode 100644 index 00000000..5fd85692 --- /dev/null +++ b/tests/Unit/Traits/SiteHelpersTraitTest.php @@ -0,0 +1,82 @@ +command = $container->build(\Bigpixelrocket\DeployerPHP\Tests\Fixtures\TestConsoleCommand::class); + $this->tester = new CommandTester($this->command); + }); + + // + // displaySiteDeets + // ------------------------------------------------------------------------------- + + it('displays git site information with server formatting', function (array $servers) { + // ARRANGE + $this->command->setTestMethod('displaySiteDeets', [ + new SiteDTO( + domain: 'example.com', + repo: 'git@github.com:user/repo.git', + branch: 'main', + servers: $servers + ), + ]); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('Domain:') + ->and($output)->toContain('example.com') + ->and($output)->toContain('Type:') + ->and($output)->toContain('Git') + ->and($output)->toContain('Repo:') + ->and($output)->toContain('git@github.com:user/repo.git') + ->and($output)->toContain('Branch:') + ->and($output)->toContain('main') + ->and($output)->toContain('Servers:') + ->and($output)->toContain(implode(', ', $servers)); + })->with([ + 'two servers' => [['web1', 'web2']], + 'four servers' => [['web1', 'web2', 'web3', 'web4']], + ]); + + it('displays local site information with server formatting', function (array $servers, string $domain) { + // ARRANGE + $this->command->setTestMethod('displaySiteDeets', [ + new SiteDTO( + domain: $domain, + repo: null, + branch: null, + servers: $servers + ), + ]); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('Domain:') + ->and($output)->toContain($domain) + ->and($output)->toContain('Type:') + ->and($output)->toContain('Local') + ->and($output)->not->toContain('Repo:') + ->and($output)->not->toContain('Branch:') + ->and($output)->toContain('Servers:') + ->and($output)->toContain(implode(', ', $servers)); + })->with([ + 'single server' => [['web1'], 'single.com'], + 'multiple servers' => [['web1', 'web2', 'web3'], 'multi.com'], + ]); +}); diff --git a/tests/Unit/Traits/SiteValidationTraitTest.php b/tests/Unit/Traits/SiteValidationTraitTest.php new file mode 100644 index 00000000..569f220d --- /dev/null +++ b/tests/Unit/Traits/SiteValidationTraitTest.php @@ -0,0 +1,242 @@ +validateDomainInput($domain); + } + + /** + * Expose protected validateBranchInput for testing. + */ + public function testValidateBranch(mixed $branch): ?string + { + return $this->validateBranchInput($branch); + } + + /** + * Expose protected validateGitRepo for testing. + */ + public function testValidateGitRepo(string $repo): void + { + $this->validateGitRepo($repo); + } + + /** + * Expose protected validateServers for testing. + * + * @param array $serverNames + */ + public function testValidateServers(array $serverNames): void + { + $this->validateServers($serverNames); + } +} + +// +// Unit tests +// ------------------------------------------------------------------------------- + +require_once __DIR__ . '/../../TestHelpers.php'; + +describe('SiteValidationTrait', function () { + beforeEach(function () { + $this->validator = new TestSiteValidator(); + }); + + // + // validateDomainInput + // ------------------------------------------------------------------------------- + + it('accepts valid domain names', function (string $domain) { + // ARRANGE + $this->validator->sites = mockSiteRepository(true, ['sites' => []]); + + // ACT + $error = $this->validator->testValidateDomain($domain); + + // ASSERT + expect($error)->toBeNull(); + })->with([ + 'simple domain' => ['example.com'], + 'subdomain' => ['blog.example.com'], + 'deep subdomain' => ['api.app.example.com'], + 'hyphenated domain' => ['my-site.example.com'], + 'numeric in domain' => ['site1.example.com'], + 'single letter' => ['x.com'], + 'long TLD' => ['example.agency'], + ]); + + it('rejects invalid domain formats with error messages', function (string $domain, string $expectedError) { + // ARRANGE + $this->validator->sites = mockSiteRepository(true, ['sites' => []]); + + // ACT + $error = $this->validator->testValidateDomain($domain); + + // ASSERT + expect($error)->not->toBeNull() + ->and($error)->toContain($expectedError); + })->with([ + 'empty string' => ['', 'valid domain name'], + 'underscore' => ['example_site.com', 'valid domain name'], + 'spaces' => ['my site.com', 'valid domain name'], + 'special chars' => ['site!@#.com', 'valid domain name'], + 'double dots' => ['example..com', 'valid domain name'], + 'starts with dot' => ['.example.com', 'valid domain name'], + ]); + + it('rejects duplicate domains', function () { + // ARRANGE + $this->validator->sites = mockSiteRepository(true, [ + 'sites' => [ + ['domain' => 'existing.com', 'servers' => ['web1']], + ], + ]); + + // ACT + $error = $this->validator->testValidateDomain('existing.com'); + + // ASSERT + expect($error)->toContain('already exists in inventory'); + }); + + it('rejects non-string domain input', function () { + // ARRANGE + $this->validator->sites = mockSiteRepository(true, ['sites' => []]); + + // ACT + $error = $this->validator->testValidateDomain(123); + + // ASSERT + expect($error)->toBe('Domain must be a string'); + }); + + // + // validateBranchInput + // ------------------------------------------------------------------------------- + + it('accepts valid branch names', function (string $branch) { + // ACT + $error = $this->validator->testValidateBranch($branch); + + // ASSERT + expect($error)->toBeNull(); + })->with([ + 'main' => ['main'], + 'master' => ['master'], + 'develop' => ['develop'], + 'feature branch' => ['feature/new-ui'], + 'bugfix branch' => ['bugfix/issue-123'], + 'release branch' => ['release/v1.2.3'], + 'numeric' => ['123'], + 'with dots' => ['feature.test'], + 'with underscores' => ['feature_branch'], + ]); + + it('rejects empty branch names', function () { + // ACT + $error = $this->validator->testValidateBranch(''); + + // ASSERT + expect($error)->toContain('cannot be empty'); + }); + + it('rejects whitespace-only branch names', function () { + // ACT + $error = $this->validator->testValidateBranch(' '); + + // ASSERT + expect($error)->toContain('cannot be empty'); + }); + + it('rejects non-string branch input', function () { + // ACT + $error = $this->validator->testValidateBranch(123); + + // ASSERT + expect($error)->toBe('Branch name must be a string'); + }); + + // + // validateGitRepo (exception-throwing method) + // ------------------------------------------------------------------------------- + // + // Note: validateGitRepo performs heavy I/O (network calls to git repositories) + // and is tested via integration tests in command test suites. + + // + // validateServers (exception-throwing method) + // ------------------------------------------------------------------------------- + + it('throws exception when no servers are selected', function () { + // ARRANGE + $this->validator->servers = mockServerRepository(true, ['servers' => []]); + + // ACT & ASSERT + expect(fn () => $this->validator->testValidateServers([])) + ->toThrow(\RuntimeException::class, 'At least one server must be selected'); + }); + + it('throws exception when server is not found in inventory', function () { + // ARRANGE + $this->validator->servers = mockServerRepository(true, [ + 'servers' => [ + ['name' => 'web1', 'host' => '192.168.1.1', 'port' => 22, 'username' => 'root'], + ], + ]); + + // ACT & ASSERT + expect(fn () => $this->validator->testValidateServers(['nonexistent'])) + ->toThrow(\RuntimeException::class, "Server 'nonexistent' not found in inventory"); + }); + + it('passes validation when all servers exist', function () { + // ARRANGE + $this->validator->servers = mockServerRepository(true, [ + 'servers' => [ + ['name' => 'web1', 'host' => '192.168.1.1', 'port' => 22, 'username' => 'root'], + ['name' => 'web2', 'host' => '192.168.1.2', 'port' => 22, 'username' => 'root'], + ], + ]); + + // ACT & ASSERT - Should not throw + $this->validator->testValidateServers(['web1', 'web2']); + expect(true)->toBeTrue(); + }); + + it('throws exception when one of multiple servers is not found', function () { + // ARRANGE + $this->validator->servers = mockServerRepository(true, [ + 'servers' => [ + ['name' => 'web1', 'host' => '192.168.1.1', 'port' => 22, 'username' => 'root'], + ], + ]); + + // ACT & ASSERT + expect(fn () => $this->validator->testValidateServers(['web1', 'nonexistent'])) + ->toThrow(\RuntimeException::class, "Server 'nonexistent' not found in inventory"); + }); +}); From e9eb30c731934c95b6270534054b406cf231f00c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 16 Oct 2025 13:27:31 +0300 Subject: [PATCH 06/17] test(server): update server command tests for consistency Update ServerAddCommandTest, ServerDeleteCommandTest, and ServerListCommandTest to align with new testing patterns and standards. --- .../Console/Server/ServerAddCommandTest.php | 159 +----------------- .../Server/ServerDeleteCommandTest.php | 102 ++++------- .../Console/Server/ServerListCommandTest.php | 131 ++++++++------- 3 files changed, 111 insertions(+), 281 deletions(-) diff --git a/tests/Integration/Console/Server/ServerAddCommandTest.php b/tests/Integration/Console/Server/ServerAddCommandTest.php index 9175e8e3..3a12ca54 100644 --- a/tests/Integration/Console/Server/ServerAddCommandTest.php +++ b/tests/Integration/Console/Server/ServerAddCommandTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use Bigpixelrocket\DeployerPHP\Console\Server\ServerAddCommand; -use Bigpixelrocket\DeployerPHP\Services\SSHService; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; @@ -13,9 +12,9 @@ require_once __DIR__ . '/../../../TestHelpers.php'; -function createServerAddCommandTester(?SSHService $sshService = null): CommandTester +function createServerAddCommandTester(): CommandTester { - $container = mockCommandContainer(ssh: $sshService); + $container = mockCommandContainer(); $command = $container->build(ServerAddCommand::class); return new CommandTester($command); } @@ -31,8 +30,7 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe it('adds server with all options provided non-interactively', function () { // ARRANGE - $sshService = mockSSHServiceWithBehavior(true); - $tester = createServerAddCommandTester($sshService); + $tester = createServerAddCommandTester(); // ACT - Provide all options for fully non-interactive execution ob_start(); @@ -42,8 +40,6 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe '--port' => '2222', '--username' => 'deployer', '--private-key-path' => '~/.ssh/prod_key', - '--skip' => true, - '--yes' => true, ]); ob_end_clean(); @@ -60,8 +56,7 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe it('adds server with minimal options using defaults', function () { // ARRANGE - $sshService = mockSSHServiceWithBehavior(true); - $tester = createServerAddCommandTester($sshService); + $tester = createServerAddCommandTester(); // ACT - Provide all required options to avoid prompting ob_start(); @@ -71,8 +66,6 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe '--port' => '22', '--username' => 'root', '--private-key-path' => '', - '--skip' => true, - '--yes' => true, ]); ob_end_clean(); @@ -86,66 +79,13 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe ->and($output)->toContain('root'); }); - it('adds server with successful SSH connection test', function () { - // ARRANGE - $sshService = mockSSHServiceWithBehavior(true); - $tester = createServerAddCommandTester($sshService); - - // ACT - Provide all required options - ob_start(); - $exitCode = $tester->execute([ - '--name' => 'test-server', - '--host' => '10.0.0.1', - '--port' => '22', - '--username' => 'root', - '--private-key-path' => '', - '--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 - Provide all required options - ob_start(); - $exitCode = $tester->execute([ - '--name' => 'untested-server', - '--host' => '192.168.1.50', - '--port' => '22', - '--username' => 'root', - '--private-key-path' => '', - '--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); + $tester = createServerAddCommandTester(); // ACT ob_start(); @@ -155,8 +95,6 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe '--port' => '22', '--username' => 'root', '--private-key-path' => '', - '--skip' => true, - '--yes' => true, ]); ob_end_clean(); @@ -173,8 +111,7 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe it('rejects invalid port with helpful error message', function (string $invalidPort) { // ARRANGE - $sshService = mockSSHServiceWithBehavior(true); - $tester = createServerAddCommandTester($sshService); + $tester = createServerAddCommandTester(); // ACT ob_start(); @@ -184,8 +121,6 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe '--port' => $invalidPort, '--username' => 'root', '--private-key-path' => '', - '--skip' => true, - '--yes' => true, ]); ob_end_clean(); @@ -203,8 +138,7 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe it('prevents duplicate server names', function () { // ARRANGE - $sshService = mockSSHServiceWithBehavior(true); - $tester = createServerAddCommandTester($sshService); + $tester = createServerAddCommandTester(); // ACT - Add first server (capture output) ob_start(); @@ -214,8 +148,6 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe '--port' => '22', '--username' => 'root', '--private-key-path' => '', - '--skip' => true, - '--yes' => true, ]); ob_end_clean(); @@ -227,8 +159,6 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe '--port' => '22', '--username' => 'root', '--private-key-path' => '', - '--skip' => true, - '--yes' => true, ]); ob_end_clean(); @@ -242,8 +172,7 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe it('prevents duplicate server hosts', function () { // ARRANGE - $sshService = mockSSHServiceWithBehavior(true); - $tester = createServerAddCommandTester($sshService); + $tester = createServerAddCommandTester(); // ACT - Add first server (capture output) ob_start(); @@ -253,8 +182,6 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe '--port' => '22', '--username' => 'root', '--private-key-path' => '', - '--skip' => true, - '--yes' => true, ]); ob_end_clean(); @@ -266,8 +193,6 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe '--port' => '22', '--username' => 'root', '--private-key-path' => '', - '--skip' => true, - '--yes' => true, ]); ob_end_clean(); @@ -278,72 +203,4 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe ->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); - $tester = createServerAddCommandTester($sshService); - - // ACT - Provide all required options - ob_start(); - $exitCode = $tester->execute([ - '--name' => 'unreachable', - '--host' => '192.168.1.99', - '--port' => '22', - '--username' => 'root', - '--private-key-path' => '', - '--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'); - }); - - // - // Inventory Persistence & Display - // ------------------------------------------------------------------------------- - - it('displays complete server information and persists to inventory', function () { - // ARRANGE - $sshService = mockSSHServiceWithBehavior(true); - $tester = createServerAddCommandTester($sshService); - - // ACT - Provide all required options - ob_start(); - $exitCode = $tester->execute([ - '--name' => 'complete-test', - '--host' => 'example.com', - '--port' => '8022', - '--username' => 'deployer', - '--private-key-path' => '~/.ssh/key', - '--skip' => true, - '--yes' => true, - ]); - ob_end_clean(); - - // ASSERT - Verify display AND persistence - $output = $tester->getDisplay(); - expect($exitCode)->toBe(Command::SUCCESS) - ->and($output)->toContain('Name:') - ->and($output)->toContain('complete-test') - ->and($output)->toContain('Host:') - ->and($output)->toContain('example.com') - ->and($output)->toContain('Port:') - ->and($output)->toContain('8022') - ->and($output)->toContain('User:') - ->and($output)->toContain('deployer') - ->and($output)->toContain('Key:') - ->and($output)->toContain('~/.ssh/key') - ->and($output)->toContain('✓') - ->and($output)->toContain('Server added successfully'); - }); }); diff --git a/tests/Integration/Console/Server/ServerDeleteCommandTest.php b/tests/Integration/Console/Server/ServerDeleteCommandTest.php index 1add7f0c..e48f58e0 100644 --- a/tests/Integration/Console/Server/ServerDeleteCommandTest.php +++ b/tests/Integration/Console/Server/ServerDeleteCommandTest.php @@ -13,19 +13,22 @@ require_once __DIR__ . '/../../../TestHelpers.php'; -function createServerDeleteCommandTester(array $existingServers = []): CommandTester +function createServerDeleteCommandTester(array $existingServers = [], array $existingSites = []): CommandTester { - // Pre-populate repository with test servers - $inventoryData = empty($existingServers) ? ['servers' => []] : ['servers' => array_map( - fn (ServerDTO $server) => [ - 'name' => $server->name, - 'host' => $server->host, - 'port' => $server->port, - 'username' => $server->username, - 'privateKeyPath' => $server->privateKeyPath, - ], - $existingServers - )]; + // Pre-populate repository with test servers and sites + $inventoryData = [ + 'servers' => array_map( + fn (ServerDTO $server) => [ + 'name' => $server->name, + 'host' => $server->host, + 'port' => $server->port, + 'username' => $server->username, + 'privateKeyPath' => $server->privateKeyPath, + ], + $existingServers + ), + 'sites' => $existingSites, + ]; $container = mockCommandContainer(inventoryData: $inventoryData); $command = $container->build(ServerDeleteCommand::class); @@ -64,26 +67,6 @@ function createServerDeleteCommandTester(array $existingServers = []): CommandTe ->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([ - '--server' => 'production', - '--yes' => true, - ]); - - // ASSERT - $output = $tester->getDisplay(); - expect($exitCode)->toBe(Command::SUCCESS) - ->and($output)->toContain('✓') - ->and($output)->toContain('deleted successfully'); - }); - // // Error Scenarios // ------------------------------------------------------------------------------- @@ -123,55 +106,30 @@ function createServerDeleteCommandTester(array $existingServers = []): CommandTe ->and($output)->toContain('server:add'); }); - // - // Display Verification - // ------------------------------------------------------------------------------- - - it('displays server information before deletion', function (ServerDTO $server, array $expectedOutput) { - // ARRANGE - $tester = createServerDeleteCommandTester([$server]); - - // ACT - $tester->execute([ - '--server' => $server->name, - '--yes' => true, - ]); - - // ASSERT - $output = $tester->getDisplay(); - foreach ($expectedOutput as $expected) { - expect($output)->toContain($expected); - } - })->with([ - 'custom key path' => [ - new ServerDTO('custom-key', 'example.com', 2222, 'deployer', '~/.ssh/key'), - ['Name:', 'custom-key', 'Host:', 'example.com', 'Port:', '2222', 'User:', 'deployer', 'Key:', '~/.ssh/key'], - ], - 'default key path' => [ - new ServerDTO('default-key', '192.168.1.1', 22, 'root', null), - ['Name:', 'default-key', 'Host:', '192.168.1.1', 'Port:', '22', 'User:', 'root', 'Key:', 'default', '~/.ssh/id_ed25519', '~/.ssh/id_rsa'], - ], - ]); - - it('shows command hint with correct parameters', function () { + it('prevents deletion when server has sites', function () { // ARRANGE $existingServers = [ - new ServerDTO('hint-test', '192.168.1.1', 22, 'root', null), + new ServerDTO('web1', '192.168.1.1', 22, 'root', null), ]; - $tester = createServerDeleteCommandTester($existingServers); + $existingSites = [ + ['domain' => 'example.com', 'repo' => 'git@github.com:user/repo.git', 'branch' => 'main', 'servers' => ['web1']], + ['domain' => 'app.example.com', 'servers' => ['web1']], + ]; + $tester = createServerDeleteCommandTester($existingServers, $existingSites); // ACT - $tester->execute([ - '--server' => 'hint-test', + $exitCode = $tester->execute([ + '--server' => 'web1', '--yes' => true, ]); // ASSERT $output = $tester->getDisplay(); - expect($output)->toContain('Run non-interactively:') - ->and($output)->toContain('server:delete') - ->and($output)->toContain('--server') - ->and($output)->toContain('hint-test') - ->and($output)->toContain('--yes'); + expect($exitCode)->toBe(Command::FAILURE) + ->and($output)->toContain('✗') + ->and($output)->toContain("Cannot delete server 'web1' because it has one or more sites") + ->and($output)->toContain('Sites:') + ->and($output)->toContain('example.com') + ->and($output)->toContain('app.example.com'); }); }); diff --git a/tests/Integration/Console/Server/ServerListCommandTest.php b/tests/Integration/Console/Server/ServerListCommandTest.php index 0146ec7a..d73c5627 100644 --- a/tests/Integration/Console/Server/ServerListCommandTest.php +++ b/tests/Integration/Console/Server/ServerListCommandTest.php @@ -4,6 +4,7 @@ use Bigpixelrocket\DeployerPHP\Console\Server\ServerListCommand; use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; +use Bigpixelrocket\DeployerPHP\DTOs\SiteDTO; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; @@ -13,19 +14,34 @@ require_once __DIR__ . '/../../../TestHelpers.php'; -function createServerListCommandTester(array $existingServers = []): CommandTester +/** + * @param array $existingServers + * @param array $existingSites + */ +function createServerListCommandTester(array $existingServers = [], array $existingSites = []): CommandTester { - // Pre-populate repository with test servers - $inventoryData = empty($existingServers) ? ['servers' => []] : ['servers' => array_map( - fn (ServerDTO $server) => [ - 'name' => $server->name, - 'host' => $server->host, - 'port' => $server->port, - 'username' => $server->username, - 'privateKeyPath' => $server->privateKeyPath, - ], - $existingServers - )]; + // Build inventory data with servers and sites + $inventoryData = [ + 'servers' => array_map( + fn (ServerDTO $server) => [ + 'name' => $server->name, + 'host' => $server->host, + 'port' => $server->port, + 'username' => $server->username, + 'privateKeyPath' => $server->privateKeyPath, + ], + $existingServers + ), + 'sites' => array_map( + fn (SiteDTO $site) => [ + 'domain' => $site->domain, + 'repo' => $site->repo, + 'branch' => $site->branch, + 'servers' => $site->servers, + ], + $existingSites + ), + ]; $container = mockCommandContainer(inventoryData: $inventoryData); $command = $container->build(ServerListCommand::class); @@ -41,7 +57,7 @@ function createServerListCommandTester(array $existingServers = []): CommandTest // Success Scenarios // ------------------------------------------------------------------------------- - it('lists multiple servers with full details', function () { + it('lists servers with full details', function () { // ARRANGE $existingServers = [ new ServerDTO('web1', '192.168.1.1', 22, 'root', null), @@ -68,27 +84,6 @@ function createServerListCommandTester(array $existingServers = []): CommandTest ->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 // ------------------------------------------------------------------------------- @@ -109,10 +104,10 @@ function createServerListCommandTester(array $existingServers = []): CommandTest ->and($output)->not->toContain('All Servers'); }); - it('displays default SSH key message for servers without custom keys', function () { + it('displays SSH key path correctly', function (?string $keyPath, array $expectedOutput) { // ARRANGE $existingServers = [ - new ServerDTO('default-key', '192.168.1.1', 22, 'root', null), + new ServerDTO('test-server', '192.168.1.1', 22, 'root', $keyPath), ]; $tester = createServerListCommandTester($existingServers); @@ -122,18 +117,32 @@ function createServerListCommandTester(array $existingServers = []): CommandTest // 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'); - }); + ->and($output)->toContain('Key:'); - it('displays custom SSH key paths correctly', function () { + foreach ($expectedOutput as $expected) { + expect($output)->toContain($expected); + } + })->with([ + 'default key' => [null, ['default', '~/.ssh/id_ed25519', '~/.ssh/id_rsa']], + 'custom key' => ['~/.ssh/special_key', ['~/.ssh/special_key']], + ]); + + // + // Site Display + // ------------------------------------------------------------------------------- + + it('displays sites under their respective servers', function () { // ARRANGE $existingServers = [ - new ServerDTO('custom-key', '192.168.1.1', 22, 'root', '~/.ssh/special_key'), + new ServerDTO('web1', '192.168.1.1', 22, 'root', null), + new ServerDTO('web2', '192.168.1.2', 22, 'root', null), ]; - $tester = createServerListCommandTester($existingServers); + $existingSites = [ + new SiteDTO('example.com', 'https://github.com/user/repo.git', 'main', ['web1']), + new SiteDTO('test.com', null, null, ['web2']), + new SiteDTO('shared.com', 'https://github.com/user/shared.git', 'dev', ['web1', 'web2']), + ]; + $tester = createServerListCommandTester($existingServers, $existingSites); // ACT $exitCode = $tester->execute([]); @@ -141,31 +150,37 @@ function createServerListCommandTester(array $existingServers = []): CommandTest // ASSERT $output = $tester->getDisplay(); expect($exitCode)->toBe(Command::SUCCESS) - ->and($output)->toContain('Key:') - ->and($output)->toContain('~/.ssh/special_key') - ->and($output)->not->toContain('default'); + ->and($output)->toContain('Sites:') + ->and($output)->toContain('example.com') + ->and($output)->toContain('test.com') + ->and($output)->toContain('shared.com'); + + // Verify sites appear after their servers + $web1Pos = strpos($output, 'web1'); + $examplePos = strpos($output, 'example.com'); + $sharedPos = strpos($output, 'shared.com'); + $web2Pos = strpos($output, 'web2'); + $testPos = strpos($output, 'test.com'); + + expect($web1Pos)->toBeLessThan($examplePos) + ->and($web1Pos)->toBeLessThan($sharedPos) + ->and($web2Pos)->toBeLessThan($testPos); }); - it('lists servers in order they appear in inventory', function () { + it('displays no sites section when server has no sites', 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), + new ServerDTO('web1', '192.168.1.1', 22, 'root', null), ]; - $tester = createServerListCommandTester($existingServers); + $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); + ->and($output)->toContain('web1') + ->and($output)->not->toContain('Sites:'); }); }); From 4abe7a75bc8de53e4e1594a7ea2c7001fa6cf269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 16 Oct 2025 13:50:53 +0300 Subject: [PATCH 07/17] feat(services): add GitService for git repository operations - Extracts git detection logic into reusable service layer - Provides detectRemoteUrl() and detectCurrentBranch() methods - Supports custom working directory parameter - Includes comprehensive unit tests with 7 test cases - Follows architecture rules for business logic separation --- app/Services/GitService.php | 81 +++++++++++++++++ tests/Unit/Services/GitServiceTest.php | 119 +++++++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 app/Services/GitService.php create mode 100644 tests/Unit/Services/GitServiceTest.php diff --git a/app/Services/GitService.php b/app/Services/GitService.php new file mode 100644 index 00000000..8ac2dc8e --- /dev/null +++ b/app/Services/GitService.php @@ -0,0 +1,81 @@ +proc->run( + ['git', 'config', '--get', 'remote.origin.url'], + $cwd, + 2.0 + ); + + if ($process->isSuccessful()) { + return trim($process->getOutput()); + } + + return null; + } catch (\Exception) { + return null; + } + } + + /** + * Detect current git branch name from a working directory. + * + * @param string|null $workingDir Working directory to run git command in (defaults to current) + * @return string|null The branch name, or null if not in a git repo or command fails + */ + public function detectCurrentBranch(?string $workingDir = null): ?string + { + try { + $cwd = $workingDir ?? getcwd(); + if ($cwd === false) { + return null; + } + + $process = $this->proc->run( + ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], + $cwd, + 2.0 + ); + + if ($process->isSuccessful()) { + return trim($process->getOutput()); + } + + return null; + } catch (\Exception) { + return null; + } + } +} diff --git a/tests/Unit/Services/GitServiceTest.php b/tests/Unit/Services/GitServiceTest.php new file mode 100644 index 00000000..6be545d3 --- /dev/null +++ b/tests/Unit/Services/GitServiceTest.php @@ -0,0 +1,119 @@ +detectRemoteUrl(); + + // ASSERT - In a git repo, this will detect the origin URL; in non-git, returns null + if ($url !== null) { + expect($url)->toBeString(); + } else { + expect($url)->toBeNull(); + } + }); + + it('returns null when not in a git repository', function () { + // ARRANGE + $git = mockGitService(); + $tempDir = sys_get_temp_dir() . '/test_non_git_' . uniqid(); + mkdir($tempDir, 0755, true); + + try { + // ACT + $url = $git->detectRemoteUrl($tempDir); + + // ASSERT + expect($url)->toBeNull(); + } finally { + rmdir($tempDir); + } + }); + + it('returns null for invalid working directory', function () { + // ARRANGE + $git = mockGitService(); + + // ACT + $url = $git->detectRemoteUrl('/non/existent/directory'); + + // ASSERT + expect($url)->toBeNull(); + }); + + // + // detectCurrentBranch + // ------------------------------------------------------------------------------- + + it('detects git branch from current directory', function () { + // ARRANGE + $git = mockGitService(); + + // ACT + $branch = $git->detectCurrentBranch(); + + // ASSERT - In a git repo, returns branch name; in non-git, returns null + if ($branch !== null) { + expect($branch)->toBeString(); + } else { + expect($branch)->toBeNull(); + } + }); + + it('returns null when not in a git repository for branch', function () { + // ARRANGE + $git = mockGitService(); + $tempDir = sys_get_temp_dir() . '/test_non_git_' . uniqid(); + mkdir($tempDir, 0755, true); + + try { + // ACT + $branch = $git->detectCurrentBranch($tempDir); + + // ASSERT + expect($branch)->toBeNull(); + } finally { + rmdir($tempDir); + } + }); + + it('returns null for invalid working directory for branch', function () { + // ARRANGE + $git = mockGitService(); + + // ACT + $branch = $git->detectCurrentBranch('/non/existent/directory'); + + // ASSERT + expect($branch)->toBeNull(); + }); + + it('trims whitespace from output', function () { + // ARRANGE + $git = mockGitService(); + + // ACT - Both methods should trim output + $url = $git->detectRemoteUrl(__DIR__); + $branch = $git->detectCurrentBranch(__DIR__); + + // ASSERT - If not null, should not have leading/trailing whitespace + if ($url !== null) { + expect($url)->toBe(trim($url)); + } + if ($branch !== null) { + expect($branch)->toBe(trim($branch)); + } + }); +}); From 7a576ef8d895b7c94893c6ecf4b4dad41a557754 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 16 Oct 2025 13:50:58 +0300 Subject: [PATCH 08/17] refactor(di): integrate GitService into dependency injection - Add GitService to BaseCommand constructor (alphabetically ordered) - Update mockCommandContainer() to bind GitService - Add mockGitService() test helper function - Update TestConsoleCommand to include GitService parameter - Add selectServers() and selectSite() to test methods - Add --servers option to test command configuration --- app/Contracts/BaseCommand.php | 2 ++ tests/Fixtures/TestConsoleCommand.php | 8 +++++++- tests/TestHelpers.php | 21 +++++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/app/Contracts/BaseCommand.php b/app/Contracts/BaseCommand.php index 5a20519b..197e616d 100644 --- a/app/Contracts/BaseCommand.php +++ b/app/Contracts/BaseCommand.php @@ -8,6 +8,7 @@ use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository; use Bigpixelrocket\DeployerPHP\Repositories\SiteRepository; use Bigpixelrocket\DeployerPHP\Services\EnvService; +use Bigpixelrocket\DeployerPHP\Services\GitService; use Bigpixelrocket\DeployerPHP\Services\InventoryService; use Bigpixelrocket\DeployerPHP\Services\IOService; use Bigpixelrocket\DeployerPHP\Services\ProcessService; @@ -38,6 +39,7 @@ public function __construct( // Base services protected readonly EnvService $env, + protected readonly GitService $git, protected readonly InventoryService $inventory, protected readonly IOService $io, protected readonly ProcessService $proc, diff --git a/tests/Fixtures/TestConsoleCommand.php b/tests/Fixtures/TestConsoleCommand.php index fe20345a..85ddf785 100644 --- a/tests/Fixtures/TestConsoleCommand.php +++ b/tests/Fixtures/TestConsoleCommand.php @@ -9,6 +9,7 @@ use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository; use Bigpixelrocket\DeployerPHP\Repositories\SiteRepository; use Bigpixelrocket\DeployerPHP\Services\EnvService; +use Bigpixelrocket\DeployerPHP\Services\GitService; use Bigpixelrocket\DeployerPHP\Services\InventoryService; use Bigpixelrocket\DeployerPHP\Services\IOService; use Bigpixelrocket\DeployerPHP\Services\ProcessService; @@ -38,6 +39,7 @@ class TestConsoleCommand extends BaseCommand * * @param Container $container Dependency injection container. * @param EnvService $env Environment service. + * @param GitService $git Git service for repository operations. * @param InventoryService $inventory Inventory management service. * @param IOService $io I/O service for console operations. * @param ProcessService $proc Process execution service. @@ -48,6 +50,7 @@ class TestConsoleCommand extends BaseCommand public function __construct( Container $container, EnvService $env, + GitService $git, InventoryService $inventory, IOService $io, ProcessService $proc, @@ -55,7 +58,7 @@ public function __construct( SiteRepository $sites, SSHService $ssh, ) { - parent::__construct($container, $env, $inventory, $io, $proc, $servers, $sites, $ssh); + parent::__construct($container, $env, $git, $inventory, $io, $proc, $servers, $sites, $ssh); } /** @@ -73,6 +76,7 @@ protected function configure(): void $this->setName('test-console')->setDescription('Test console trait methods'); $this->addOption('name', null, InputOption::VALUE_REQUIRED, 'Test name option'); $this->addOption('host', null, InputOption::VALUE_REQUIRED, 'Test host option'); + $this->addOption('servers', null, InputOption::VALUE_REQUIRED, 'Test servers option'); $this->addOption('yes', 'y', InputOption::VALUE_NONE, 'Test yes flag'); } @@ -90,6 +94,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'showCommandHint' => $this->io->showCommandHint(...$this->testArgs), 'displayServerDeets' => $this->displayServerDeets(...$this->testArgs), 'displaySiteDeets' => $this->displaySiteDeets(...$this->testArgs), + 'selectServers' => $this->selectServers(), + 'selectSite' => $this->selectSite(), 'getOptionOrPrompt' => $this->testGetOptionOrPrompt(), 'getOptionOrPromptEmpty' => $this->testGetOptionOrPromptEmpty(), 'getOptionOrPromptBoolean' => $this->testGetOptionOrPromptBoolean(), diff --git a/tests/TestHelpers.php b/tests/TestHelpers.php index b1648051..11be533d 100644 --- a/tests/TestHelpers.php +++ b/tests/TestHelpers.php @@ -7,6 +7,7 @@ use Bigpixelrocket\DeployerPHP\Repositories\SiteRepository; use Bigpixelrocket\DeployerPHP\Services\EnvService; use Bigpixelrocket\DeployerPHP\Services\FilesystemService; +use Bigpixelrocket\DeployerPHP\Services\GitService; use Bigpixelrocket\DeployerPHP\Services\InventoryService; use Bigpixelrocket\DeployerPHP\Services\IOService; use Bigpixelrocket\DeployerPHP\Services\ProcessService; @@ -257,6 +258,23 @@ function mockVersionService( } } +if (!function_exists('mockGitService')) { + /** + * Create a GitService for testing with mocked ProcessService. + * + * Returns a GitService instance with a real ProcessService for testing git command execution. + * + * @example + * $git = mockGitService(); + * // Use in tests that need git functionality + */ + function mockGitService(): GitService + { + $proc = mockProcessService(); + return new GitService($proc); + } +} + // // Repository Layer Mocks // ------------------------------------------------------------------------------- @@ -347,6 +365,7 @@ function mockSiteRepository( function mockCommandContainer( // Base services (alphabetical order) ?EnvService $env = null, + ?GitService $git = null, ?InventoryService $inventory = null, ?IOService $io = null, ?ProcessService $proc = null, @@ -366,6 +385,7 @@ function mockCommandContainer( // Build or use provided services (matches BaseCommand constructor order) $env ??= mockEnvService($envFileExists, $envContent); + $git ??= mockGitService(); $inventory ??= mockInventoryService($inventoryFileExists, $inventoryData); $io ??= mockIOService(); $proc ??= mockProcessService(); @@ -375,6 +395,7 @@ function mockCommandContainer( // Bind services to container (matches BaseCommand constructor order) $container->bind(EnvService::class, $env); + $container->bind(GitService::class, $git); $container->bind(InventoryService::class, $inventory); $container->bind(IOService::class, $io); $container->bind(ProcessService::class, $proc); From 11875102f71280071c617f86f5d5f510de895be5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 16 Oct 2025 13:51:04 +0300 Subject: [PATCH 09/17] refactor(site:add): use GitService and add server validation - Replace private git detection methods with GitService calls - Add early server existence validation for CLI options - Wrap selectServers() in try-catch for error handling - Reduces command-layer business logic per architecture rules - Improves user experience with immediate validation feedback --- app/Console/Site/SiteAddCommand.php | 71 ++++------------------------- 1 file changed, 9 insertions(+), 62 deletions(-) diff --git a/app/Console/Site/SiteAddCommand.php b/app/Console/Site/SiteAddCommand.php index 902ef6be..9b8df5bf 100644 --- a/app/Console/Site/SiteAddCommand.php +++ b/app/Console/Site/SiteAddCommand.php @@ -110,7 +110,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $branch = null; if (!$isLocal) { - $defaultRepo = $this->detectGitRemote() ?? 'git@github.com:user/repo.git'; + $defaultRepo = $this->git->detectRemoteUrl() ?? 'git@github.com:user/repo.git'; /** @var string $repo */ $repo = $this->io->getOptionOrPrompt( @@ -123,7 +123,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ) ); - $defaultBranch = $this->detectGitBranch() ?? 'main'; + $defaultBranch = $this->git->detectCurrentBranch() ?? 'main'; /** @var string|null $branch */ $branch = $this->io->getValidatedOptionOrPrompt( @@ -146,7 +146,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int // // Select servers - $selectedServers = $this->selectServers(); + try { + $selectedServers = $this->selectServers(); + } catch (\RuntimeException $e) { + $this->io->error($e->getMessage()); + + return Command::FAILURE; + } // // Validate selections @@ -205,63 +211,4 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } - - // - // Private Helpers - // ------------------------------------------------------------------------------- - - /** - * Detect git remote origin URL from current directory. - */ - private function detectGitRemote(): ?string - { - try { - $cwd = getcwd(); - if ($cwd === false) { - return null; - } - - $process = $this->proc->run( - ['git', 'config', '--get', 'remote.origin.url'], - $cwd, - 2.0 - ); - - if ($process->isSuccessful()) { - return trim($process->getOutput()); - } - - return null; - } catch (\Exception) { - return null; - } - } - - /** - * Detect current git branch name. - */ - private function detectGitBranch(): ?string - { - try { - $cwd = getcwd(); - if ($cwd === false) { - return null; - } - - $process = $this->proc->run( - ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], - $cwd, - 2.0 - ); - - if ($process->isSuccessful()) { - return trim($process->getOutput()); - } - - return null; - } catch (\Exception) { - return null; - } - } - } From dc39e95039f5228b9aa0169e5c50d25fa25b06c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 16 Oct 2025 13:51:08 +0300 Subject: [PATCH 10/17] refactor(helpers): add server validation in selectServers() - Validate CLI-provided server names immediately during parsing - Throw descriptive RuntimeException for non-existent servers - Add unit tests for successful and failed validations - Provides early feedback for CLI option errors - Interactive prompts already validated via UI constraints --- app/Traits/SiteHelpersTrait.php | 7 ++++ tests/Unit/Traits/SiteHelpersTraitTest.php | 38 ++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/app/Traits/SiteHelpersTrait.php b/app/Traits/SiteHelpersTrait.php index 6942dcd7..ebf69bd3 100644 --- a/app/Traits/SiteHelpersTrait.php +++ b/app/Traits/SiteHelpersTrait.php @@ -105,6 +105,13 @@ protected function selectServers(string $optionName = 'servers'): array if (is_string($serversInput)) { // Parse comma-separated server names from CLI option $selectedServers = array_map('trim', explode(',', $serversInput)); + + // Validate servers exist + foreach ($selectedServers as $serverName) { + if ($this->servers->findByName($serverName) === null) { + throw new \RuntimeException("Server '{$serverName}' not found in inventory"); + } + } } else { // Already an array from interactive prompt $selectedServers = $serversInput; diff --git a/tests/Unit/Traits/SiteHelpersTraitTest.php b/tests/Unit/Traits/SiteHelpersTraitTest.php index 5fd85692..4959d082 100644 --- a/tests/Unit/Traits/SiteHelpersTraitTest.php +++ b/tests/Unit/Traits/SiteHelpersTraitTest.php @@ -79,4 +79,42 @@ 'single server' => [['web1'], 'single.com'], 'multiple servers' => [['web1', 'web2', 'web3'], 'multi.com'], ]); + + // + // selectServers (CLI validation) + // ------------------------------------------------------------------------------- + + it('validates server names in CLI option', function () { + // ARRANGE + $container = mockCommandContainer( + inventoryData: ['servers' => [ + ['name' => 'web1', 'host' => '192.168.1.1', 'port' => 22, 'username' => 'root'], + ['name' => 'web2', 'host' => '192.168.1.2', 'port' => 22, 'username' => 'root'], + ]] + ); + $command = $container->build(\Bigpixelrocket\DeployerPHP\Tests\Fixtures\TestConsoleCommand::class); + $command->setTestMethod('selectServers'); + + // ACT & ASSERT + $tester = new CommandTester($command); + $exitCode = $tester->execute(['--servers' => 'web1,web2']); + + expect($exitCode)->toBe(\Symfony\Component\Console\Command\Command::SUCCESS); + }); + + it('rejects non-existent server names from CLI option', function () { + // ARRANGE + $container = mockCommandContainer( + inventoryData: ['servers' => [ + ['name' => 'web1', 'host' => '192.168.1.1', 'port' => 22, 'username' => 'root'], + ]] + ); + $command = $container->build(\Bigpixelrocket\DeployerPHP\Tests\Fixtures\TestConsoleCommand::class); + $command->setTestMethod('selectServers'); + + // ACT & ASSERT + $tester = new CommandTester($command); + expect(fn () => $tester->execute(['--servers' => 'web1,non-existent'])) + ->toThrow(\RuntimeException::class, "Server 'non-existent' not found"); + }); }); From 3cb768022b75eb2257fd41cb75610c1aed5cdf68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 16 Oct 2025 13:51:12 +0300 Subject: [PATCH 11/17] test(site:list): consolidate site listing tests - Merge single/multiple site tests into dataset-driven test - Reduce redundant assertions and test overlap - Decrease file size from 133 to 106 lines - Improve test-to-code ratio from 2.38x to 1.89x - Maintains comprehensive coverage with 5 test scenarios --- .../Console/Site/SiteListCommandTest.php | 60 ++++++++----------- 1 file changed, 25 insertions(+), 35 deletions(-) diff --git a/tests/Integration/Console/Site/SiteListCommandTest.php b/tests/Integration/Console/Site/SiteListCommandTest.php index c5cf78d7..8a81b6f9 100644 --- a/tests/Integration/Console/Site/SiteListCommandTest.php +++ b/tests/Integration/Console/Site/SiteListCommandTest.php @@ -45,13 +45,12 @@ function createSiteListCommandTester(array $existingSites = []): CommandTester // Success Scenarios // ------------------------------------------------------------------------------- - it('lists multiple sites with full details', function () { + it('lists sites with full details', function (array $sites, array $expectedOutputs) { // ARRANGE - $existingSites = [ - new SiteDTO('example.com', 'git@github.com:user/repo.git', 'main', ['web1']), - new SiteDTO('app.example.com', 'git@github.com:user/app.git', 'develop', ['web2']), - new SiteDTO('local.test', null, null, ['web1']), - ]; + $existingSites = array_map( + fn (array $data) => new SiteDTO($data['domain'], $data['repo'] ?? null, $data['branch'] ?? null, $data['servers']), + $sites + ); $tester = createSiteListCommandTester($existingSites); // ACT @@ -60,36 +59,27 @@ function createSiteListCommandTester(array $existingSites = []): CommandTester // ASSERT $output = $tester->getDisplay(); expect($exitCode)->toBe(Command::SUCCESS) - ->and($output)->toContain('▸') - ->and($output)->toContain('All Sites') - ->and($output)->toContain('example.com') - ->and($output)->toContain('git@github.com:user/repo.git') - ->and($output)->toContain('main') - ->and($output)->toContain('app.example.com') - ->and($output)->toContain('develop') - ->and($output)->toContain('local.test') - ->and($output)->toContain('Local'); - }); - - it('lists single site with complete details', function () { - // ARRANGE - $existingSites = [ - new SiteDTO('production.com', 'git@github.com:company/prod.git', 'production', ['web1', 'web2']), - ]; - $tester = createSiteListCommandTester($existingSites); + ->and($output)->toContain('All Sites'); - // ACT - $exitCode = $tester->execute([]); - - // ASSERT - $output = $tester->getDisplay(); - expect($exitCode)->toBe(Command::SUCCESS) - ->and($output)->toContain('All Sites') - ->and($output)->toContain('production.com') - ->and($output)->toContain('git@github.com:company/prod.git') - ->and($output)->toContain('production') - ->and($output)->toContain('web1, web2'); - }); + foreach ($expectedOutputs as $expected) { + expect($output)->toContain($expected); + } + })->with([ + 'multiple sites' => [ + [ + ['domain' => 'example.com', 'repo' => 'git@github.com:user/repo.git', 'branch' => 'main', 'servers' => ['web1']], + ['domain' => 'app.example.com', 'repo' => 'git@github.com:user/app.git', 'branch' => 'develop', 'servers' => ['web2']], + ['domain' => 'local.test', 'servers' => ['web1']], + ], + ['example.com', 'git@github.com:user/repo.git', 'main', 'app.example.com', 'develop', 'local.test', 'Local'], + ], + 'single site' => [ + [ + ['domain' => 'production.com', 'repo' => 'git@github.com:company/prod.git', 'branch' => 'production', 'servers' => ['web1', 'web2']], + ], + ['production.com', 'git@github.com:company/prod.git', 'production', 'web1, web2'], + ], + ]); // // Edge Cases From c306d0ff60e1b62414c72218c568ce1df8f2ba54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 16 Oct 2025 13:51:15 +0300 Subject: [PATCH 12/17] chore(gitignore): update to ignore generated files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d30beda4..39dfdf47 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .deployer/ +.cursor/.agent-tools/ .experiments/ .idea/ .vscode/ From 6ca6b24ca8e875e5736ec4169ad2506da88ae09b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 16 Oct 2025 14:28:15 +0300 Subject: [PATCH 13/17] feat(site): add git repository URL validation Add validateRepoInput() method to SiteValidationTrait to validate git repository URL format before prompting. Validates that URLs start with git@, https://, http://, or ssh://. Update SiteAddCommand to use getValidatedOptionOrPrompt() instead of getOptionOrPrompt() for repository input, providing real-time validation as users type and preventing invalid URLs from being accepted. --- app/Console/Site/SiteAddCommand.php | 20 +++++++++++------ app/Traits/SiteValidationTrait.php | 34 +++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/app/Console/Site/SiteAddCommand.php b/app/Console/Site/SiteAddCommand.php index 9b8df5bf..51e1e138 100644 --- a/app/Console/Site/SiteAddCommand.php +++ b/app/Console/Site/SiteAddCommand.php @@ -110,19 +110,25 @@ protected function execute(InputInterface $input, OutputInterface $output): int $branch = null; if (!$isLocal) { - $defaultRepo = $this->git->detectRemoteUrl() ?? 'git@github.com:user/repo.git'; + $defaultRepo = $this->git->detectRemoteUrl() ?? ''; - /** @var string $repo */ - $repo = $this->io->getOptionOrPrompt( + /** @var string|null $repo */ + $repo = $this->io->getValidatedOptionOrPrompt( 'repo', - fn (): string => $this->io->promptText( + fn ($validate) => $this->io->promptText( label: 'Git repository URL:', - placeholder: $defaultRepo, + placeholder: 'git@github.com:user/repo.git', default: $defaultRepo, - required: true - ) + required: true, + validate: $validate + ), + fn ($value) => $this->validateRepoInput($value) ); + if ($repo === null) { + return Command::FAILURE; + } + $defaultBranch = $this->git->detectCurrentBranch() ?? 'main'; /** @var string|null $branch */ diff --git a/app/Traits/SiteValidationTrait.php b/app/Traits/SiteValidationTrait.php index 51114b36..8cb3ac3f 100644 --- a/app/Traits/SiteValidationTrait.php +++ b/app/Traits/SiteValidationTrait.php @@ -55,6 +55,40 @@ protected function validateBranchInput(mixed $branch): ?string return null; } + /** + * Validate git repository URL format. + * + * @return string|null Error message if invalid, null if valid + */ + protected function validateRepoInput(mixed $repo): ?string + { + if (!is_string($repo)) { + return 'Repository URL must be a string'; + } + + if (trim($repo) === '') { + return 'Repository URL cannot be empty'; + } + + // Basic format check - should start with git@, https://, http://, or ssh:// + $repo = trim($repo); + $validPrefixes = ['git@', 'https://', 'http://', 'ssh://']; + $hasValidPrefix = false; + + foreach ($validPrefixes as $prefix) { + if (str_starts_with($repo, $prefix)) { + $hasValidPrefix = true; + break; + } + } + + if (!$hasValidPrefix) { + return 'Repository URL must start with git@, https://, http://, or ssh://'; + } + + return null; + } + /** * Validate git repository is accessible. * From 2d443e530d312617873230b68177719b2a2b1c01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 16 Oct 2025 14:28:27 +0300 Subject: [PATCH 14/17] test(site): add repository URL validation tests Add comprehensive test coverage for validateRepoInput() method using dataset-driven testing. Tests cover valid URLs (HTTPS, HTTP, SSH formats), invalid formats (missing protocol, wrong protocol, paths), empty/whitespace input, and type validation. --- tests/Unit/Traits/SiteValidationTraitTest.php | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/tests/Unit/Traits/SiteValidationTraitTest.php b/tests/Unit/Traits/SiteValidationTraitTest.php index 569f220d..227727c0 100644 --- a/tests/Unit/Traits/SiteValidationTraitTest.php +++ b/tests/Unit/Traits/SiteValidationTraitTest.php @@ -36,6 +36,14 @@ public function testValidateBranch(mixed $branch): ?string return $this->validateBranchInput($branch); } + /** + * Expose protected validateRepoInput for testing. + */ + public function testValidateRepo(mixed $repo): ?string + { + return $this->validateRepoInput($repo); + } + /** * Expose protected validateGitRepo for testing. */ @@ -180,6 +188,69 @@ public function testValidateServers(array $serverNames): void expect($error)->toBe('Branch name must be a string'); }); + // + // validateRepoInput + // ------------------------------------------------------------------------------- + + it('accepts valid git repository URLs', function (string $repo) { + // ACT + $error = $this->validator->testValidateRepo($repo); + + // ASSERT + expect($error)->toBeNull(); + })->with([ + 'HTTPS GitHub' => ['https://github.com/user/repo.git'], + 'HTTPS GitLab' => ['https://gitlab.com/user/repo.git'], + 'HTTPS Bitbucket' => ['https://bitbucket.org/user/repo.git'], + 'HTTP URL' => ['http://example.com/repo.git'], + 'SSH GitHub' => ['git@github.com:user/repo.git'], + 'SSH GitLab' => ['git@gitlab.com:user/repo.git'], + 'SSH Bitbucket' => ['git@bitbucket.org:user/repo.git'], + 'SSH protocol' => ['ssh://git@github.com/user/repo.git'], + 'HTTPS without .git' => ['https://github.com/user/repo'], + 'SSH custom port' => ['ssh://git@example.com:2222/repo.git'], + 'HTTPS with subdomain' => ['https://git.example.com/repo.git'], + ]); + + it('rejects empty repository URLs', function () { + // ACT + $error = $this->validator->testValidateRepo(''); + + // ASSERT + expect($error)->toContain('cannot be empty'); + }); + + it('rejects whitespace-only repository URLs', function () { + // ACT + $error = $this->validator->testValidateRepo(' '); + + // ASSERT + expect($error)->toContain('cannot be empty'); + }); + + it('rejects invalid repository URL formats', function (string $repo, string $expectedError) { + // ACT + $error = $this->validator->testValidateRepo($repo); + + // ASSERT + expect($error)->not->toBeNull() + ->and($error)->toContain($expectedError); + })->with([ + 'no protocol' => ['github.com/user/repo.git', 'must start with'], + 'invalid protocol' => ['ftp://github.com/user/repo.git', 'must start with'], + 'plain path' => ['/path/to/repo', 'must start with'], + 'relative path' => ['../repo', 'must start with'], + 'just domain' => ['example.com', 'must start with'], + ]); + + it('rejects non-string repository input', function () { + // ACT + $error = $this->validator->testValidateRepo(123); + + // ASSERT + expect($error)->toBe('Repository URL must be a string'); + }); + // // validateGitRepo (exception-throwing method) // ------------------------------------------------------------------------------- From 8b093848049dd97fc0c7e0229de2c5c5c36f4012 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 16 Oct 2025 14:28:37 +0300 Subject: [PATCH 15/17] fix(io): improve error message spacing in validation Add blank line after validation error messages in getValidatedOptionOrPrompt() to improve visual separation between error output and subsequent prompts, enhancing readability in interactive CLI workflows. --- app/Services/IOService.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Services/IOService.php b/app/Services/IOService.php index 105336f1..d895f4fc 100644 --- a/app/Services/IOService.php +++ b/app/Services/IOService.php @@ -172,6 +172,7 @@ public function getValidatedOptionOrPrompt( $error = $validator($value); if ($error !== null) { $this->error($error); + $this->writeln(''); return null; } From 11ed8ea9dabc42623398bb6ba5f2cbed2a1086d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 16 Oct 2025 14:41:10 +0300 Subject: [PATCH 16/17] fix: rector --- app/Services/GitService.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Services/GitService.php b/app/Services/GitService.php index 8ac2dc8e..11b8b351 100644 --- a/app/Services/GitService.php +++ b/app/Services/GitService.php @@ -9,9 +9,9 @@ * * Provides utilities for detecting git repository information. */ -final class GitService +final readonly class GitService { - public function __construct(private readonly ProcessService $proc) + public function __construct(private ProcessService $proc) { } From 6e81082a6ac671fead1cf9be71f11bed69a06735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 16 Oct 2025 15:00:16 +0300 Subject: [PATCH 17/17] refactor(services): extract runGitCommand helper in GitService Extract common git command execution pattern from detectRemoteUrl() and detectCurrentBranch() into a private runGitCommand() helper method. This reduces code duplication and improves maintainability by centralizing git command execution logic in a single place. --- app/Services/GitService.php | 49 ++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/app/Services/GitService.php b/app/Services/GitService.php index 11b8b351..e499a63a 100644 --- a/app/Services/GitService.php +++ b/app/Services/GitService.php @@ -27,26 +27,10 @@ public function __construct(private ProcessService $proc) */ public function detectRemoteUrl(?string $workingDir = null): ?string { - try { - $cwd = $workingDir ?? getcwd(); - if ($cwd === false) { - return null; - } - - $process = $this->proc->run( - ['git', 'config', '--get', 'remote.origin.url'], - $cwd, - 2.0 - ); - - if ($process->isSuccessful()) { - return trim($process->getOutput()); - } - - return null; - } catch (\Exception) { - return null; - } + return $this->runGitCommand( + ['git', 'config', '--get', 'remote.origin.url'], + $workingDir + ); } /** @@ -56,6 +40,25 @@ public function detectRemoteUrl(?string $workingDir = null): ?string * @return string|null The branch name, or null if not in a git repo or command fails */ public function detectCurrentBranch(?string $workingDir = null): ?string + { + return $this->runGitCommand( + ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], + $workingDir + ); + } + + // + // Helpers + // ------------------------------------------------------------------------------- + + /** + * Run a git command and return trimmed output or null on failure. + * + * @param list $cmd Git command and arguments + * @param string|null $workingDir Working directory (defaults to current) + * @return string|null Command output or null on failure + */ + private function runGitCommand(array $cmd, ?string $workingDir): ?string { try { $cwd = $workingDir ?? getcwd(); @@ -63,11 +66,7 @@ public function detectCurrentBranch(?string $workingDir = null): ?string return null; } - $process = $this->proc->run( - ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], - $cwd, - 2.0 - ); + $process = $this->proc->run($cmd, $cwd, 2.0); if ($process->isSuccessful()) { return trim($process->getOutput());