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/ 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; diff --git a/app/Console/Site/SiteAddCommand.php b/app/Console/Site/SiteAddCommand.php new file mode 100644 index 00000000..51e1e138 --- /dev/null +++ b/app/Console/Site/SiteAddCommand.php @@ -0,0 +1,220 @@ +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->git->detectRemoteUrl() ?? ''; + + /** @var string|null $repo */ + $repo = $this->io->getValidatedOptionOrPrompt( + 'repo', + fn ($validate) => $this->io->promptText( + label: 'Git repository URL:', + placeholder: 'git@github.com:user/repo.git', + default: $defaultRepo, + required: true, + validate: $validate + ), + fn ($value) => $this->validateRepoInput($value) + ); + + if ($repo === null) { + return Command::FAILURE; + } + + $defaultBranch = $this->git->detectCurrentBranch() ?? '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 + + try { + $selectedServers = $this->selectServers(); + } catch (\RuntimeException $e) { + $this->io->error($e->getMessage()); + + return Command::FAILURE; + } + + // + // 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; + } +} 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/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/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')) : [], ); } diff --git a/app/Services/GitService.php b/app/Services/GitService.php new file mode 100644 index 00000000..e499a63a --- /dev/null +++ b/app/Services/GitService.php @@ -0,0 +1,80 @@ +runGitCommand( + ['git', 'config', '--get', 'remote.origin.url'], + $workingDir + ); + } + + /** + * 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 + { + 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(); + if ($cwd === false) { + return null; + } + + $process = $this->proc->run($cmd, $cwd, 2.0); + + if ($process->isSuccessful()) { + return trim($process->getOutput()); + } + + return null; + } catch (\Exception) { + return null; + } + } +} 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; } 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/app/Traits/SiteHelpersTrait.php b/app/Traits/SiteHelpersTrait.php new file mode 100644 index 00000000..ebf69bd3 --- /dev/null +++ b/app/Traits/SiteHelpersTrait.php @@ -0,0 +1,144 @@ +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)); + + // 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; + } + + // 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..8cb3ac3f --- /dev/null +++ b/app/Traits/SiteValidationTrait.php @@ -0,0 +1,144 @@ +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 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. + * + * @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"); + } + } + } +} diff --git a/tests/Fixtures/TestConsoleCommand.php b/tests/Fixtures/TestConsoleCommand.php index 4563ad4d..85ddf785 100644 --- a/tests/Fixtures/TestConsoleCommand.php +++ b/tests/Fixtures/TestConsoleCommand.php @@ -9,11 +9,13 @@ 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; 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 +29,7 @@ class TestConsoleCommand extends BaseCommand { use ServerHelpersTrait; + use SiteHelpersTrait; private string $methodToTest = ''; private array $testArgs = []; @@ -36,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. @@ -46,6 +50,7 @@ class TestConsoleCommand extends BaseCommand public function __construct( Container $container, EnvService $env, + GitService $git, InventoryService $inventory, IOService $io, ProcessService $proc, @@ -53,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); } /** @@ -71,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'); } @@ -87,6 +93,9 @@ 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), + 'selectServers' => $this->selectServers(), + 'selectSite' => $this->selectSite(), 'getOptionOrPrompt' => $this->testGetOptionOrPrompt(), 'getOptionOrPromptEmpty' => $this->testGetOptionOrPromptEmpty(), 'getOptionOrPromptBoolean' => $this->testGetOptionOrPromptBoolean(), diff --git a/tests/Integration/Console/Server/ServerAddCommandTest.php b/tests/Integration/Console/Server/ServerAddCommandTest.php 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:'); }); }); 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..8a81b6f9 --- /dev/null +++ b/tests/Integration/Console/Site/SiteListCommandTest.php @@ -0,0 +1,123 @@ + $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 sites with full details', function (array $sites, array $expectedOutputs) { + // ARRANGE + $existingSites = array_map( + fn (array $data) => new SiteDTO($data['domain'], $data['repo'] ?? null, $data['branch'] ?? null, $data['servers']), + $sites + ); + $tester = createSiteListCommandTester($existingSites); + + // ACT + $exitCode = $tester->execute([]); + + // ASSERT + $output = $tester->getDisplay(); + expect($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toContain('All Sites'); + + 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 + // ------------------------------------------------------------------------------- + + 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/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); 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/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)); + } + }); +}); diff --git a/tests/Unit/Traits/SiteHelpersTraitTest.php b/tests/Unit/Traits/SiteHelpersTraitTest.php new file mode 100644 index 00000000..4959d082 --- /dev/null +++ b/tests/Unit/Traits/SiteHelpersTraitTest.php @@ -0,0 +1,120 @@ +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'], + ]); + + // + // 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"); + }); +}); diff --git a/tests/Unit/Traits/SiteValidationTraitTest.php b/tests/Unit/Traits/SiteValidationTraitTest.php new file mode 100644 index 00000000..227727c0 --- /dev/null +++ b/tests/Unit/Traits/SiteValidationTraitTest.php @@ -0,0 +1,313 @@ +validateDomainInput($domain); + } + + /** + * Expose protected validateBranchInput for testing. + */ + 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. + */ + 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'); + }); + + // + // 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) + // ------------------------------------------------------------------------------- + // + // 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"); + }); +});