diff --git a/app/Contracts/BaseCommand.php b/app/Contracts/BaseCommand.php index b7f85fd3..43c510ba 100644 --- a/app/Contracts/BaseCommand.php +++ b/app/Contracts/BaseCommand.php @@ -6,8 +6,10 @@ use Bigpixelrocket\DeployerPHP\Container; use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository; +use Bigpixelrocket\DeployerPHP\Repositories\SiteRepository; use Bigpixelrocket\DeployerPHP\Services\EnvService; use Bigpixelrocket\DeployerPHP\Services\InventoryService; +use Bigpixelrocket\DeployerPHP\Services\ProcessService; use Bigpixelrocket\DeployerPHP\Services\PrompterService; use Bigpixelrocket\DeployerPHP\Services\SSHService; use Bigpixelrocket\DeployerPHP\Traits\ConsoleInputTrait; @@ -33,13 +35,27 @@ abstract class BaseCommand extends Command protected OutputInterface $output; protected SymfonyStyle $io; + /** + * Create a new BaseCommand with the application's services and repositories. + * + * The constructor accepts and stores dependencies (environment and inventory services, + * process and prompting helpers, server/site repositories, SSH service, and the DI container) + * used by this command and its subclasses. + */ public function __construct( + // Framework protected readonly Container $container, + + // Base services protected readonly EnvService $env, protected readonly InventoryService $inventory, + protected readonly ProcessService $proc, + protected readonly PrompterService $prompter, + + // Servers & sites protected readonly ServerRepository $servers, + protected readonly SiteRepository $sites, protected readonly SSHService $ssh, - protected readonly PrompterService $prompter, ) { parent::__construct(); } @@ -71,7 +87,15 @@ protected function configure(): void } /** - * Initialize IO and services. + * Prepare console IO and initialize environment, inventory, and repositories. + * + * Sets the command's input/output properties, creates a SymfonyStyle IO helper, + * applies any custom paths provided via the `--env` and `--inventory` options, + * loads the corresponding files, and populates the servers and sites repositories + * from the loaded inventory. + * + * @param InputInterface $input The current console input. + * @param OutputInterface $output The current console output. */ protected function initialize(InputInterface $input, OutputInterface $output): void { @@ -101,6 +125,7 @@ protected function initialize(InputInterface $input, OutputInterface $output): v // Initialize repositories $this->servers->loadInventory($this->inventory); + $this->sites->loadInventory($this->inventory); } // @@ -132,4 +157,4 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } -} +} \ No newline at end of file diff --git a/app/DTOs/SiteDTO.php b/app/DTOs/SiteDTO.php new file mode 100644 index 00000000..4bb075f3 --- /dev/null +++ b/app/DTOs/SiteDTO.php @@ -0,0 +1,24 @@ + $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 array $servers, + ) { + } +} \ No newline at end of file diff --git a/app/Repositories/SiteRepository.php b/app/Repositories/SiteRepository.php new file mode 100644 index 00000000..4394bab6 --- /dev/null +++ b/app/Repositories/SiteRepository.php @@ -0,0 +1,181 @@ +> */ + private array $sites = []; + + // + // Public + // ------------------------------------------------------------------------------- + + /** + * Configure the repository with an InventoryService and load site entries from storage. + * + * Loads the value stored under the repository's PREFIX key into the internal sites cache + * ($this->sites). If the stored value is not an array, an empty array is persisted under + * the PREFIX key and loaded into the cache. + */ + public function loadInventory(InventoryService $inventory): void + { + $this->inventory = $inventory; + + $sites = $inventory->get(self::PREFIX); + if (!is_array($sites)) { + $sites = []; + $inventory->set(self::PREFIX, $sites); + } + + /** @var array> $sites */ + $this->sites = $sites; + } + + /** + * Add a new site to the inventory storage ensuring the site's domain is unique. + * + * @param SiteDTO $site The site to store; its domain must not already exist in inventory. + * @throws \RuntimeException If the inventory has not been loaded or a site with the same domain already exists. + */ + public function create(SiteDTO $site): void + { + $this->assertInventoryLoaded(); + + $existing = $this->findByDomain($site->domain); + if (null !== $existing) { + throw new \RuntimeException("Site '{$site->domain}' already exists"); + } + + $this->sites[] = $this->dehydrateSiteDTO($site); + + $this->inventory->set(self::PREFIX, $this->sites); + } + + /** + * Retrieve the site matching the given domain. + * + * @throws \RuntimeException If the inventory has not been loaded via loadInventory(). + * @return SiteDTO|null The SiteDTO for the matching domain, or `null` if no match is found. + */ + public function findByDomain(string $domain): ?SiteDTO + { + $this->assertInventoryLoaded(); + + foreach ($this->sites as $site) { + if (isset($site['domain']) && $site['domain'] === $domain) { + return $this->hydrateSiteDTO($site); + } + } + + return null; + } + + /** + * Retrieve all stored sites as SiteDTO objects. + * + * @return array An array of SiteDTO objects. + */ + public function all(): array + { + $this->assertInventoryLoaded(); + + $result = []; + foreach ($this->sites as $site) { + $result[] = $this->hydrateSiteDTO($site); + } + + return $result; + } + + /** + * Remove the site with the given domain from the stored inventory. + * + * If no site matches the domain, the inventory remains unchanged. + * + * @param string $domain The domain of the site to remove. + */ + public function delete(string $domain): void + { + $this->assertInventoryLoaded(); + + $filtered = []; + foreach ($this->sites as $site) { + if (isset($site['domain']) && $site['domain'] !== $domain) { + $filtered[] = $site; + } + } + + $this->sites = $filtered; + + $this->inventory->set(self::PREFIX, $this->sites); + } + + // + // Private + // ------------------------------------------------------------------------------- + + /** + * Asserts that the repository's inventory service has been loaded. + * + * @throws \RuntimeException If the inventory service has not been loaded. + * @phpstan-assert !null $this->inventory + */ + private function assertInventoryLoaded(): void + { + if ($this->inventory === null) { + throw new \RuntimeException('Inventory not set. Call loadInventory() first.'); + } + } + + /** + * 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`. + */ + private function dehydrateSiteDTO(SiteDTO $site): array + { + return [ + 'domain' => $site->domain, + 'repo' => $site->repo, + 'branch' => $site->branch, + 'servers' => $site->servers, + ]; + } + + /** + * 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). + */ + private function hydrateSiteDTO(array $data): SiteDTO + { + $domain = $data['domain'] ?? ''; + $repo = $data['repo'] ?? ''; + $branch = $data['branch'] ?? ''; + $servers = $data['servers'] ?? []; + + return new SiteDTO( + domain: is_string($domain) ? $domain : '', + repo: is_string($repo) ? $repo : '', + branch: is_string($branch) ? $branch : '', + servers: is_array($servers) ? array_values(array_filter($servers, 'is_string')) : [], + ); + } +} \ No newline at end of file diff --git a/app/Services/ProcessFactory.php b/app/Services/ProcessFactory.php deleted file mode 100644 index fa0e71c2..00000000 --- a/app/Services/ProcessFactory.php +++ /dev/null @@ -1,39 +0,0 @@ - $command - */ - public function create(array $command, string $cwd, ?float $timeout = 3.0): Process - { - if ($command === []) { - throw new \InvalidArgumentException('Process command cannot be empty'); - } - - if (!$this->fs->isDirectory($cwd)) { - throw new \InvalidArgumentException("Invalid working directory: {$cwd}"); - } - - $process = new Process($command, $cwd); - $process->setTimeout($timeout ?? 3.0); - - return $process; - } -} diff --git a/app/Services/ProcessService.php b/app/Services/ProcessService.php new file mode 100644 index 00000000..13d5052d --- /dev/null +++ b/app/Services/ProcessService.php @@ -0,0 +1,49 @@ + $command The command and its arguments. + * @param string $cwd The working directory in which to execute the command. + * @param float $timeout Process timeout in seconds. + * @return Process The Symfony Process instance after execution. + * @throws \InvalidArgumentException If `$command` is empty or `$cwd` is not a directory. + */ + public function run(array $command, string $cwd, float $timeout = 3.0): Process + { + if ($command === []) { + throw new \InvalidArgumentException('Process command cannot be empty'); + } + + if (!$this->fs->isDirectory($cwd)) { + throw new \InvalidArgumentException("Invalid working directory: {$cwd}"); + } + + $process = new Process($command, $cwd); + $process->setTimeout($timeout); + $process->run(); + + return $process; + } +} \ No newline at end of file diff --git a/app/Services/VersionService.php b/app/Services/VersionService.php index e6667400..7271cdd4 100644 --- a/app/Services/VersionService.php +++ b/app/Services/VersionService.php @@ -17,8 +17,14 @@ */ class VersionService { + /** + * Create a VersionService configured with process and filesystem services and package/fallback version. + * + * @param string $packageName The Composer package name to query for version information (default 'bigpixelrocket/deployer-php'). + * @param string $fallbackVersion The version to return when no other source provides one (default 'dev-main'). + */ public function __construct( - private readonly ProcessFactory $processFactory, + private readonly ProcessService $proc, private readonly FilesystemService $fs, private readonly string $packageName = 'bigpixelrocket/deployer-php', private readonly string $fallbackVersion = 'dev-main' @@ -109,13 +115,15 @@ public function isGitRepository(string $projectRoot): bool } /** - * Get exact git tag if HEAD is tagged. + * Retrieve the exact Git tag name that points to HEAD, if present. + * + * @param string $projectRoot Path to the Git repository root. + * @return string|null The exact tag name that points to HEAD, or `null` if HEAD is not tagged or an error occurs. */ public function getExactGitTag(string $projectRoot): ?string { try { - $process = $this->processFactory->create(['git', 'describe', '--tags', '--exact-match'], $projectRoot); - $process->run(); + $process = $this->proc->run(['git', 'describe', '--tags', '--exact-match'], $projectRoot); if ($process->isSuccessful()) { return trim($process->getOutput()); @@ -128,13 +136,17 @@ public function getExactGitTag(string $projectRoot): ?string } /** - * Get git describe version (tag + commit info). + * Determine a human-readable Git reference for the repository at the given path. + * + * Attempts to run `git describe --tags --always` and returns the trimmed output on success. + * + * @param string $projectRoot Path to the repository root where the Git command will run. + * @return string|null The described reference (tag, tag+commit, or short commit) if available, `null` otherwise. */ public function getGitDescribeVersion(string $projectRoot): ?string { try { - $process = $this->processFactory->create(['git', 'describe', '--tags', '--always'], $projectRoot); - $process->run(); + $process = $this->proc->run(['git', 'describe', '--tags', '--always'], $projectRoot); if ($process->isSuccessful()) { return trim($process->getOutput()); @@ -147,16 +159,20 @@ public function getGitDescribeVersion(string $projectRoot): ?string } /** - * Get current branch with short commit hash. + * Produce the current Git branch combined with the short commit hash. + * + * Returns a string in the format "branch@commit" where `branch` is the current branch name + * and `commit` is the short commit hash. Returns `null` if the repository information cannot + * be determined or an error occurs. + * + * @param string $projectRoot Path to the repository root. + * @return string|null The branch and short commit separated by '@', or `null` if unavailable. */ public function getBranchWithCommit(string $projectRoot): ?string { try { - $branchProcess = $this->processFactory->create(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $projectRoot); - $branchProcess->run(); - - $commitProcess = $this->processFactory->create(['git', 'rev-parse', '--short', 'HEAD'], $projectRoot); - $commitProcess->run(); + $branchProcess = $this->proc->run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $projectRoot); + $commitProcess = $this->proc->run(['git', 'rev-parse', '--short', 'HEAD'], $projectRoot); if ($branchProcess->isSuccessful() && $commitProcess->isSuccessful()) { $branch = trim($branchProcess->getOutput()); @@ -169,4 +185,4 @@ public function getBranchWithCommit(string $projectRoot): ?string return null; } -} +} \ No newline at end of file diff --git a/tests/Fixtures/TestConsoleCommand.php b/tests/Fixtures/TestConsoleCommand.php index f1a49381..c5f1ee0a 100644 --- a/tests/Fixtures/TestConsoleCommand.php +++ b/tests/Fixtures/TestConsoleCommand.php @@ -7,8 +7,10 @@ use Bigpixelrocket\DeployerPHP\Container; use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository; +use Bigpixelrocket\DeployerPHP\Repositories\SiteRepository; use Bigpixelrocket\DeployerPHP\Services\EnvService; use Bigpixelrocket\DeployerPHP\Services\InventoryService; +use Bigpixelrocket\DeployerPHP\Services\ProcessService; use Bigpixelrocket\DeployerPHP\Services\PrompterService; use Bigpixelrocket\DeployerPHP\Services\SSHService; use Bigpixelrocket\DeployerPHP\Traits\ServerHelpersTrait; @@ -29,15 +31,29 @@ class TestConsoleCommand extends BaseCommand private array $testArgs = []; + /** + * Create a TestConsoleCommand instance with the required service and repository dependencies. + * + * @param Container $container Dependency injection container. + * @param EnvService $env Environment service. + * @param InventoryService $inventory Inventory management service. + * @param ProcessService $proc Process execution service. + * @param PrompterService $prompter Interactive prompt service. + * @param ServerRepository $servers Repository for server records. + * @param SiteRepository $sites Repository for site records. + * @param SSHService $ssh SSH service for remote execution. + */ public function __construct( Container $container, EnvService $env, InventoryService $inventory, + ProcessService $proc, + PrompterService $prompter, ServerRepository $servers, + SiteRepository $sites, SSHService $ssh, - PrompterService $prompter, ) { - parent::__construct($container, $env, $inventory, $servers, $ssh, $prompter); + parent::__construct($container, $env, $inventory, $proc, $prompter, $servers, $sites, $ssh); } /** @@ -233,4 +249,4 @@ private function testPromptSearchWrapper(): void { $this->promptSearch('Test:', fn ($q) => ['a', 'b']); } -} +} \ No newline at end of file diff --git a/tests/TestHelpers.php b/tests/TestHelpers.php index f023dd40..90efbd8e 100644 --- a/tests/TestHelpers.php +++ b/tests/TestHelpers.php @@ -4,10 +4,11 @@ use Bigpixelrocket\DeployerPHP\Container; use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository; +use Bigpixelrocket\DeployerPHP\Repositories\SiteRepository; use Bigpixelrocket\DeployerPHP\Services\EnvService; use Bigpixelrocket\DeployerPHP\Services\FilesystemService; use Bigpixelrocket\DeployerPHP\Services\InventoryService; -use Bigpixelrocket\DeployerPHP\Services\ProcessFactory; +use Bigpixelrocket\DeployerPHP\Services\ProcessService; use Bigpixelrocket\DeployerPHP\Services\PrompterService; use Bigpixelrocket\DeployerPHP\Services\SSHService; use Bigpixelrocket\DeployerPHP\Services\VersionService; @@ -129,28 +130,15 @@ function mockEnvService( if (!function_exists('mockInventoryService')) { /** - * Create a mock InventoryService for testing with configurable filesystem behavior. + * Create a mock InventoryService for tests with a configurable in-memory inventory file. * - * Accepts either array data (auto-converts to YAML) or raw string content. - * Use arrays for clean test setup, strings for testing YAML parsing edge cases. + * If $data is an array, it is dumped to YAML and used as the inventory file content; if it is a string, it is used verbatim. The filesystem mock can be configured to simulate missing files or read/write errors. * - * @example - * // Using array data (recommended) - * $service = mockInventoryService( - * fileExists: true, - * data: ['servers' => ['web1' => ['host' => '192.168.1.1']]] - * ); - * - * @example - * // Using raw YAML string - * $service = mockInventoryService( - * fileExists: true, - * data: "servers:\n web1:\n host: 192.168.1.1" - * ); - * - * @example - * // Test write failures - * $service = mockInventoryService(fileExists: true, throwOnWrite: true); + * @param bool $fileExists Whether the inventory file should appear to exist. + * @param array|string $data Array to be converted to YAML or raw YAML string to use as file content. + * @param bool $throwOnRead If true, the mocked filesystem will throw on read operations. + * @param bool $throwOnWrite If true, the mocked filesystem will throw on write/dump operations. + * @return InventoryService An InventoryService backed by a mocked FilesystemService. */ function mockInventoryService( bool $fileExists = true, @@ -171,21 +159,18 @@ function mockInventoryService( } } -if (!function_exists('mockProcessFactory')) { +if (!function_exists('mockProcessService')) { /** - * Create a ProcessFactory for testing. + * Creates a ProcessService configured for tests. * - * Uses real Filesystem since directory validation requires is_dir() checks. - * Tests should use real directories (e.g., __DIR__, sys_get_temp_dir()). + * Uses a real FilesystemService (Symfony Filesystem) so directory validation relies on is_dir(); tests should provide real directories (e.g., __DIR__, sys_get_temp_dir()). * - * @example - * $factory = mockProcessFactory(); - * $process = $factory->create(['echo', 'test'], __DIR__); + * @return ProcessService A ProcessService backed by a FilesystemService using a real Filesystem. */ - function mockProcessFactory(): ProcessFactory + function mockProcessService(): ProcessService { $filesystemService = new FilesystemService(new Filesystem()); - return new ProcessFactory($filesystemService); + return new ProcessService($filesystemService); } } @@ -271,32 +256,25 @@ function mockPrompter( if (!function_exists('mockVersionService')) { /** - * Create a VersionService for testing with configurable package name and fallback. - * - * Uses real Filesystem and ProcessFactory since git operations require real directory checks. + * Create a VersionService configured for tests with an optional package name and fallback version. * - * @example - * // Default configuration - * $service = mockVersionService(); + * Uses a real Filesystem and ProcessService because version resolution may perform git/directory checks. * - * @example - * // Custom package and fallback - * $service = mockVersionService( - * packageName: 'vendor/package', - * fallback: '1.0.0-dev' - * ); + * @param string|null $packageName Optional package name to use (e.g., "vendor/package"). If omitted the service uses its default discovery. + * @param string|null $fallback Optional fallback version string used when the package/version cannot be determined. + * @return VersionService The configured VersionService instance. */ function mockVersionService( ?string $packageName = null, ?string $fallback = null ): VersionService { $filesystemService = new FilesystemService(new Filesystem()); - $processFactory = new ProcessFactory($filesystemService); + $proc = new ProcessService($filesystemService); return match (true) { - $packageName !== null && $fallback !== null => new VersionService($processFactory, $filesystemService, $packageName, $fallback), - $packageName !== null => new VersionService($processFactory, $filesystemService, $packageName), - default => new VersionService($processFactory, $filesystemService), + $packageName !== null && $fallback !== null => new VersionService($proc, $filesystemService, $packageName, $fallback), + $packageName !== null => new VersionService($proc, $filesystemService, $packageName), + default => new VersionService($proc, $filesystemService), }; } } @@ -307,23 +285,15 @@ function mockVersionService( if (!function_exists('mockServerRepository')) { /** - * Create a ServerRepository for testing with a loaded inventory service. - * - * Repository is returned fully initialized with inventory loaded and ready for use. + * Create a ServerRepository preloaded with inventory data for use in tests. * - * @example - * // Empty repository - * $repo = mockServerRepository(fileExists: true, data: ['servers' => []]); + * The returned repository has its inventory loaded from a mocked InventoryService and is ready for immediate use. * - * @example - * // Pre-populated with servers - * $repo = mockServerRepository( - * fileExists: true, - * data: ['servers' => [ - * 'web1' => ['host' => '192.168.1.1', 'port' => 22] - * ]] - * ); - * $repo->findByName('web1'); // Returns ServerDTO + * @param bool $fileExists Whether the mocked inventory file should exist. + * @param array|string $data Inventory content to load; an array will be converted to YAML, a string will be used as raw file content. + * @param bool $throwOnRead If true, the mocked filesystem will throw on read operations to simulate read errors. + * @param bool $throwOnWrite If true, the mocked filesystem will throw on write/dump operations to simulate write errors. + * @return ServerRepository A ServerRepository instance with inventory loaded from the mocked service. */ function mockServerRepository( bool $fileExists = true, @@ -341,6 +311,32 @@ function mockServerRepository( } } +if (!function_exists('mockSiteRepository')) { + /** + * Creates a SiteRepository for testing with its inventory loaded from a mocked InventoryService. + * + * @param bool $fileExists Whether the underlying inventory file should appear to exist. + * @param array|string $data Inventory contents as an array (converted to YAML) or raw YAML string. + * @param bool $throwOnRead If true, the mocked inventory service will throw on read operations. + * @param bool $throwOnWrite If true, the mocked inventory service will throw on write operations. + * @return SiteRepository A repository instance with inventory loaded and ready for use. + */ + function mockSiteRepository( + bool $fileExists = true, + array|string $data = [], + bool $throwOnRead = false, + bool $throwOnWrite = false + ): SiteRepository { + $inventory = mockInventoryService($fileExists, $data, $throwOnRead, $throwOnWrite); + $inventory->loadInventoryFile(); + + $repository = new SiteRepository(); + $repository->loadInventory($inventory); + + return $repository; + } +} + // // Command & Integration Test Mocks // ------------------------------------------------------------------------------- @@ -371,11 +367,18 @@ function mockServerRepository( * $command = $container->build(ServerListCommand::class); */ function mockCommandContainer( - ?SSHService $ssh = null, - ?PrompterService $prompter = null, + // Base services ?EnvService $env = null, ?InventoryService $inventory = null, + ?ProcessService $proc = null, + ?PrompterService $prompter = null, + + // Servers & sites ?ServerRepository $servers = null, + ?SiteRepository $sites = null, + ?SSHService $ssh = null, + + // Configuration bool $envFileExists = true, string $envContent = 'API_KEY=test_value', bool $inventoryFileExists = true, @@ -383,20 +386,24 @@ function mockCommandContainer( ): Container { $container = new Container(); - // Build or use provided services + // Build or use provided services (matches BaseCommand constructor order) $env ??= mockEnvService($envFileExists, $envContent); $inventory ??= mockInventoryService($inventoryFileExists, $inventoryData); + $proc ??= mockProcessService(); + $prompter ??= mockPrompter(); $servers ??= mockServerRepository($inventoryFileExists, $inventoryData); + $sites ??= mockSiteRepository($inventoryFileExists, $inventoryData); $ssh ??= mockSSHService(); - $prompter ??= mockPrompter(); - // Bind services to container + // Bind services to container (matches BaseCommand constructor order) $container->bind(EnvService::class, $env); $container->bind(InventoryService::class, $inventory); + $container->bind(ProcessService::class, $proc); + $container->bind(PrompterService::class, $prompter); $container->bind(ServerRepository::class, $servers); + $container->bind(SiteRepository::class, $sites); $container->bind(SSHService::class, $ssh); - $container->bind(PrompterService::class, $prompter); return $container; } -} +} \ No newline at end of file diff --git a/tests/Unit/Contracts/BaseCommandTest.php b/tests/Unit/Contracts/BaseCommandTest.php index a3430455..3f659bb2 100644 --- a/tests/Unit/Contracts/BaseCommandTest.php +++ b/tests/Unit/Contracts/BaseCommandTest.php @@ -4,13 +4,9 @@ namespace Bigpixelrocket\DeployerPHP\Tests\Unit\Contracts; -use Bigpixelrocket\DeployerPHP\Container; use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository; -use Bigpixelrocket\DeployerPHP\Services\EnvService; -use Bigpixelrocket\DeployerPHP\Services\InventoryService; -use Bigpixelrocket\DeployerPHP\Services\PrompterService; -use Bigpixelrocket\DeployerPHP\Services\SSHService; +use Bigpixelrocket\DeployerPHP\Repositories\SiteRepository; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -24,22 +20,10 @@ class TestableBaseCommand extends BaseCommand { - public function __construct( - Container $container, - EnvService $env, - InventoryService $inventory, - ServerRepository $servers, - SSHService $ssh, - PrompterService $prompter, - private readonly string $testName = 'test-command', - ) { - parent::__construct($container, $env, $inventory, $servers, $ssh, $prompter); - } - protected function configure(): void { parent::configure(); - $this->setName($this->testName)->setDescription('Test command for BaseCommand testing'); + $this->setName('test-command')->setDescription('Test command for BaseCommand testing'); } protected function execute(InputInterface $input, OutputInterface $output): int @@ -57,18 +41,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int describe('BaseCommand', function () { it('constructs with dependencies and registers custom options', function () { // ARRANGE - $container = new Container(); - $env = mockEnvService(true); - $inventory = mockInventoryService(true); - $servers = mockServerRepository(); - $ssh = mockSSHService(); - $prompter = mockPrompter(); - - // ACT - $command = new TestableBaseCommand($container, $env, $inventory, $servers, $ssh, $prompter, 'test'); + $container = mockCommandContainer(); + $command = $container->build(TestableBaseCommand::class); // ASSERT - expect($command->getName())->toBe('test') + expect($command->getName())->toBe('test-command') ->and($command->getDefinition()->hasOption('env'))->toBeTrue() ->and($command->getDefinition()->hasOption('inventory'))->toBeTrue() ->and($command->getDefinition()->getOption('env')->getDescription()) @@ -79,13 +56,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int it('executes with proper env and inventory status output', function (bool $hasEnvFile, string $expectedEnvMessage) { // ARRANGE - $container = new Container(); - $env = mockEnvService($hasEnvFile); - $inventory = mockInventoryService(true); - $servers = mockServerRepository(); - $ssh = mockSSHService(); - $prompter = mockPrompter(); - $command = new TestableBaseCommand($container, $env, $inventory, $servers, $ssh, $prompter); + $container = mockCommandContainer(envFileExists: $hasEnvFile); + $command = $container->build(TestableBaseCommand::class); $tester = new CommandTester($command); // ACT @@ -103,4 +75,34 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'env file exists' => [true, 'Reading variables from'], 'no env file' => [false, 'No .env file found'], ]); + + it('initializes repositories with inventory during initialization', function () { + // ARRANGE + $inventory = mockInventoryService(true, [ + 'servers' => [['name' => 'web1', 'host' => '192.168.1.1', 'port' => 22, 'user' => 'deploy']], + 'sites' => [['domain' => 'example.com', 'repo' => 'git@github.com:user/repo.git', 'branch' => 'main', 'servers' => ['web1']]], + ]); + + // Create uninitialized repositories (not using helper to avoid auto-loading) + $servers = new ServerRepository(); + $sites = new SiteRepository(); + + $container = mockCommandContainer( + inventory: $inventory, + servers: $servers, + sites: $sites + ); + + $command = $container->build(TestableBaseCommand::class); + $tester = new CommandTester($command); + + // ACT + $tester->execute([]); + + // ASSERT - Verify repositories were loaded with inventory data + expect($servers->findByName('web1'))->not->toBeNull() + ->and($servers->findByName('web1')->host)->toBe('192.168.1.1') + ->and($sites->findByDomain('example.com'))->not->toBeNull() + ->and($sites->findByDomain('example.com')->domain)->toBe('example.com'); + }); }); diff --git a/tests/Unit/DTOs/SiteDTOTest.php b/tests/Unit/DTOs/SiteDTOTest.php new file mode 100644 index 00000000..cdcd2ed1 --- /dev/null +++ b/tests/Unit/DTOs/SiteDTOTest.php @@ -0,0 +1,23 @@ +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']); + }); +}); diff --git a/tests/Unit/Repositories/SiteRepositoryTest.php b/tests/Unit/Repositories/SiteRepositoryTest.php new file mode 100644 index 00000000..8910004d --- /dev/null +++ b/tests/Unit/Repositories/SiteRepositoryTest.php @@ -0,0 +1,146 @@ + $repository->all()) + ->toThrow(\RuntimeException::class, 'Inventory not set'); + }); + + // + // CRUD Operations + // ------------------------------------------------------------------------------- + + it('handles complete CRUD lifecycle', function () { + // ARRANGE + $inventory = mockInventoryService(true, ['sites' => []]); + $inventory->loadInventoryFile(); + $repository = new SiteRepository(); + $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', '', '', []); + + $repository->create($site1); + $repository->create($site2); + + // ASSERT - Find 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']); + + // ASSERT - Find returns null for missing + expect($repository->findByDomain('nonexistent.com'))->toBeNull(); + + // ASSERT - All returns both sites + $all = $repository->all(); + expect($all)->toHaveCount(2) + ->and($all[0]->domain)->toBe('example.com') + ->and($all[1]->domain)->toBe('test.com'); + + // ACT & ASSERT - Delete + $repository->delete('example.com'); + expect($repository->findByDomain('example.com'))->toBeNull() + ->and($repository->all())->toHaveCount(1); + + // ASSERT - Delete nonexistent doesn't error + $repository->delete('never-existed.com'); + expect($repository->all())->toHaveCount(1); + }); + + it('prevents duplicate site creation', function () { + // ARRANGE + $inventory = mockInventoryService(true, ['sites' => [ + ['domain' => 'existing.com', 'repo' => 'git@github.com:user/repo.git', 'branch' => 'main', 'servers' => []], + ]]); + $inventory->loadInventoryFile(); + $repository = new SiteRepository(); + $repository->loadInventory($inventory); + + // ACT & ASSERT + expect(fn () => $repository->create(new SiteDTO('existing.com', 'git@github.com:other/repo.git', 'develop', []))) + ->toThrow(\RuntimeException::class, "Site 'existing.com' already exists"); + }); + + // + // Data Hydration Robustness + // ------------------------------------------------------------------------------- + + 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(); + $repository = new SiteRepository(); + $repository->loadInventory($inventory); + + // ACT + $sites = $repository->all(); + + // 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); + })->with([ + 'missing domain' => [ + ['repo' => 'git@github.com:user/repo.git', 'branch' => 'main', 'servers' => []], + '', 'git@github.com:user/repo.git', 'main', [], + ], + 'missing repo' => [ + ['domain' => 'example.com', 'branch' => 'main', 'servers' => []], + 'example.com', '', 'main', [], + ], + 'missing branch' => [ + ['domain' => 'example.com', 'repo' => 'git@github.com:user/repo.git', 'servers' => []], + 'example.com', 'git@github.com:user/repo.git', '', [], + ], + 'wrong domain type' => [ + ['domain' => 12345, 'repo' => 'git@github.com:user/repo.git', 'branch' => 'main'], + '', 'git@github.com:user/repo.git', 'main', [], + ], + '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', [], + ], + 'mixed servers array' => [ + ['domain' => 'example.com', 'repo' => 'git@github.com:user/repo.git', 'branch' => 'main', 'servers' => ['web1', 123, 'web2', null]], + 'example.com', 'git@github.com:user/repo.git', 'main', ['web1', 'web2'], + ], + ]); + + // + // Initialization Edge Cases + // ------------------------------------------------------------------------------- + + it('initializes empty array when sites key missing', function () { + // ARRANGE + $inventory = mockInventoryService(true, []); + $inventory->loadInventoryFile(); + $repository = new SiteRepository(); + + // ACT + $repository->loadInventory($inventory); + + // ASSERT + expect($repository->all())->toBeArray()->toBeEmpty(); + }); +}); diff --git a/tests/Unit/Services/ProcessFactoryTest.php b/tests/Unit/Services/ProcessServiceTest.php similarity index 71% rename from tests/Unit/Services/ProcessFactoryTest.php rename to tests/Unit/Services/ProcessServiceTest.php index 9491d757..95f30829 100644 --- a/tests/Unit/Services/ProcessFactoryTest.php +++ b/tests/Unit/Services/ProcessServiceTest.php @@ -5,23 +5,26 @@ require_once __DIR__ . '/../../TestHelpers.php'; -describe('ProcessFactory', function () { +describe('ProcessService', function () { beforeEach(function () { $this->validCwd = __DIR__; - $this->factory = mockProcessFactory(); + $this->proc = mockProcessService(); }); - it('configures process timeout correctly', function (?float $inputTimeout, ?float $expectedTimeout) { + it('executes process and returns result', function (?float $inputTimeout, ?float $expectedTimeout) { // ARRANGE $command = ['echo', 'test']; // ACT - $process = $this->factory->create($command, $this->validCwd, $inputTimeout); + $process = $inputTimeout === null + ? $this->proc->run($command, $this->validCwd) + : $this->proc->run($command, $this->validCwd, $inputTimeout); // ASSERT expect($process->getCommandLine())->toContain('echo') ->and($process->getWorkingDirectory())->toBe($this->validCwd) - ->and($process->getTimeout())->toBe($expectedTimeout); + ->and($process->getTimeout())->toBe($expectedTimeout) + ->and($process->isSuccessful())->toBeTrue(); })->with([ 'null timeout defaults to 3.0' => [null, 3.0], 'explicit default timeout' => [3.0, 3.0], @@ -35,7 +38,7 @@ $emptyCommand = []; // ACT & ASSERT - expect(fn () => $this->factory->create($emptyCommand, $this->validCwd)) + expect(fn () => $this->proc->run($emptyCommand, $this->validCwd)) ->toThrow(InvalidArgumentException::class, 'Process command cannot be empty'); }); @@ -44,7 +47,7 @@ $command = ['echo', 'test']; // ACT & ASSERT - expect(fn () => $this->factory->create($command, $invalidPath)) + expect(fn () => $this->proc->run($command, $invalidPath)) ->toThrow(InvalidArgumentException::class); })->with([ 'empty string' => [''],