diff --git a/.cursor/commands/improve-tests.md b/.cursor/commands/improve-tests.md new file mode 100644 index 00000000..6c15dbf4 --- /dev/null +++ b/.cursor/commands/improve-tests.md @@ -0,0 +1,3 @@ +Is there any overlap in these tests, or are any tests engaging in testing theater? + +Implement improvements if they are. diff --git a/tests/Fixtures/ContainerFixtures.php b/tests/Fixtures/ContainerFixtures.php deleted file mode 100644 index ea18c55b..00000000 --- a/tests/Fixtures/ContainerFixtures.php +++ /dev/null @@ -1,181 +0,0 @@ -service; - } -} - -class ServiceWithMultipleDeps -{ - public function __construct(private readonly SimpleService $s1, private readonly ServiceWithDependency $s2) - { - } - - public function getSimple(): SimpleService - { - return $this->s1; - } - - public function getComplex(): ServiceWithDependency - { - return $this->s2; - } -} - -// -// Services with defaults and optional dependencies -// ------------------------------------------------------------------------------- - -class ServiceWithDefaults -{ - public function __construct(private readonly SimpleService $service, private readonly string $name = 'default') - { - } - - public function getName(): string - { - return $this->name; - } -} - -class ServiceWithOptionalClassDep -{ - public function __construct(private readonly ?AbstractClass $dep = null) - { - } - - public function hasDep(): bool - { - return $this->dep !== null; - } -} - -// -// Circular dependency fixtures -// ------------------------------------------------------------------------------- - -class CircularA -{ - public function __construct(private readonly CircularB $b) - { - } -} - -class CircularB -{ - public function __construct(private readonly CircularA $a) - { - } -} - -// -// Error condition fixtures -// ------------------------------------------------------------------------------- - -class ServiceWithScalarParam -{ - public function __construct(private readonly string $required) - { - } -} - -class ServiceWithUnresolvableDependency -{ - public function __construct(private readonly AbstractClass $dependency) - { - } -} - -class PrivateConstructor -{ - private function __construct() - { - } -} - -// -// Union and intersection type fixtures -// ------------------------------------------------------------------------------- - -class ServiceWithUnionType -{ - public function __construct(private readonly SimpleService|ServiceWithDependency $service) - { - } - - public function getServiceType(): string - { - return $this->service instanceof SimpleService ? 'simple' : 'complex'; - } -} - -class ServiceWithIntersectionType -{ - public function __construct(private readonly (\Countable&\ArrayAccess)|null $data = null) - { - } - - public function hasData(): bool - { - return $this->data !== null; - } -} - -class ServiceWithUnionAndCircular -{ - public function __construct(private readonly CircularA|SimpleService $dependency) - { - } - - public function getDependency(): CircularA|SimpleService - { - return $this->dependency; - } -} - -// -// Interfaces and abstract classes -// ------------------------------------------------------------------------------- - -interface TestInterface -{ -} - -abstract class AbstractClass -{ -} diff --git a/tests/Fixtures/MockFilesystem.php b/tests/Fixtures/MockFilesystem.php deleted file mode 100644 index a8570e6d..00000000 --- a/tests/Fixtures/MockFilesystem.php +++ /dev/null @@ -1,137 +0,0 @@ -exists('config.yml'); // true - * $fs->readFile('config.yml'); // 'test data' - */ -class MockFilesystem extends Filesystem -{ - /** @var array */ - private array $files = []; - - /** @var array */ - private array $directories = []; - - public function __construct( - private readonly bool $initialExists, - private readonly string $initialContent, - private readonly bool $throwOnRead, - private readonly bool $throwOnMkdir, - private readonly bool $throwOnDump, - private readonly string $initialPath - ) { - if ($this->initialExists) { - $this->files[$this->initialPath] = $this->initialContent; - } - - // Extract parent directory from initial path if exists - $directory = dirname($this->initialPath); - $hasDirectory = $directory !== '.' && $directory !== ''; - - $this->directories = $this->throwOnMkdir ? [] : ($hasDirectory ? [$directory] : []); - } - - /** - * @param iterable|string $files - */ - public function exists(string|iterable $files): bool - { - if (is_iterable($files)) { - foreach ($files as $file) { - if (!$this->exists($file)) { - return false; - } - } - return true; - } - - // Check files (direct match or path ends with stored key) - if (isset($this->files[$files])) { - return true; - } - - foreach (array_keys($this->files) as $storedPath) { - if (str_ends_with($files, $storedPath)) { - return true; - } - } - - // Check directories (match exact path only) - foreach ($this->directories as $dir) { - if (rtrim($files, '/\\') === rtrim($dir, '/\\')) { - return true; - } - } - - return false; - } - - public function readFile(string $filename): string - { - if ($this->throwOnRead) { - throw new IOException('Permission denied', 0, null, $filename); - } - - // Try direct match first - if (array_key_exists($filename, $this->files)) { - return $this->files[$filename]; - } - - // Try path ending match - foreach ($this->files as $storedPath => $content) { - if (str_ends_with($filename, $storedPath)) { - return $content; - } - } - - throw new IOException("File does not exist: {$filename}", 0, null, $filename); - } - - /** - * @param iterable|string $dirs - */ - public function mkdir(string|iterable $dirs, int $mode = 0777): void - { - if (is_string($dirs)) { - if ($this->throwOnMkdir) { - throw new IOException('Permission denied', 0, null, $dirs); - } - $this->directories[] = $dirs; - } else { - // Handle iterable of directories - foreach ($dirs as $dir) { - $this->mkdir($dir, $mode); - } - } - } - - public function dumpFile(string $filename, mixed $content): void - { - if ($this->throwOnDump) { - throw new IOException('Write failed', 0, null, $filename); - } - - $this->files[$filename] = (string) $content; - } -} diff --git a/tests/Fixtures/MockSSHService.php b/tests/Fixtures/MockSSHService.php deleted file mode 100644 index 61b7b411..00000000 --- a/tests/Fixtures/MockSSHService.php +++ /dev/null @@ -1,87 +0,0 @@ -assertCanConnect('host', 22, 'user'); // No exception - * - * @example - * // Simulate connection failure - * $ssh = new MockSSHService(canConnect: false); - * $ssh->assertCanConnect('host', 22, 'user'); // Throws RuntimeException - */ -class MockSSHService extends SSHService -{ - public function __construct(private readonly bool $canConnect) - { - // Skip parent constructor to avoid dependency injection - } - - public function assertCanConnect( - string $host, - int $port, - string $username, - ?string $privateKeyPath = null - ): void { - if (!$this->canConnect) { - throw new \RuntimeException('Failed to connect to SSH server'); - } - // Success - no exception thrown - } - - public function executeCommand( - string $host, - int $port, - string $username, - string $command, - ?string $privateKeyPath = null - ): array { - return ['output' => 'command output', 'exit_code' => 0]; - } - - public function executeScript( - string $host, - int $port, - string $username, - string $scriptPath, - ?string $privateKeyPath = null - ): array { - return ['output' => 'script output', 'exit_code' => 0]; - } - - public function uploadFile( - string $host, - int $port, - string $username, - string $localPath, - string $remotePath, - ?string $privateKeyPath = null - ): void { - // Mock implementation - no actual upload - } - - public function downloadFile( - string $host, - int $port, - string $username, - string $remotePath, - string $localPath, - ?string $privateKeyPath = null - ): void { - // Mock implementation - no actual download - } -} diff --git a/tests/Fixtures/TestConsoleCommand.php b/tests/Fixtures/TestConsoleCommand.php deleted file mode 100644 index 85ddf785..00000000 --- a/tests/Fixtures/TestConsoleCommand.php +++ /dev/null @@ -1,295 +0,0 @@ -methodToTest = $method; - $this->testArgs = $args; - } - - protected function configure(): void - { - parent::configure(); - $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'); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - if ($this->methodToTest !== '') { - match ($this->methodToTest) { - 'info' => $this->io->info(...$this->testArgs), - 'error' => $this->io->error(...$this->testArgs), - 'success' => $this->io->success(...$this->testArgs), - 'warning' => $this->io->warning(...$this->testArgs), - 'h1' => $this->io->h1(...$this->testArgs), - 'hr' => $this->io->hr(), - '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(), - 'getOptionOrPromptTypes' => $this->testGetOptionOrPromptTypes(), - 'getValidatedOptionOrPromptValid' => $this->testGetValidatedOptionOrPromptValid(), - 'getValidatedOptionOrPromptInvalid' => $this->testGetValidatedOptionOrPromptInvalid(), - 'testPromptSpin' => $this->testPromptSpinWrapper(), - 'promptText' => $this->testPromptTextWrapper(), - 'promptPassword' => $this->testPromptPasswordWrapper(), - 'promptConfirm' => $this->testPromptConfirmWrapper(), - 'promptPause' => $this->testPromptPauseWrapper(), - 'promptSelect' => $this->testPromptSelectWrapper(), - 'promptMultiselect' => $this->testPromptMultiselectWrapper(), - 'promptSuggest' => $this->testPromptSuggestWrapper(), - 'promptSearch' => $this->testPromptSearchWrapper(), - default => null, - }; - } - - return Command::SUCCESS; - } - - /** - * Test helper for getOptionOrPrompt method. - */ - private function testGetOptionOrPrompt(): void - { - $result = $this->io->getOptionOrPrompt( - 'name', - fn () => $this->io->promptText(label: 'Name:', required: true) - ); - $this->io->writeln("Result: {$result}"); - } - - /** - * Test getOptionOrPrompt with empty string handling. - */ - private function testGetOptionOrPromptEmpty(): void - { - $closureExecuted = false; - $result = $this->io->getOptionOrPrompt( - 'name', - function () use (&$closureExecuted) { - $closureExecuted = true; - - return 'from-closure'; - } - ); - - if ($closureExecuted) { - $this->io->writeln('Closure executed'); - } - $this->io->writeln("Result: {$result}"); - } - - /** - * Test getOptionOrPrompt with boolean flag. - */ - private function testGetOptionOrPromptBoolean(): void - { - $result = $this->io->getOptionOrPrompt( - 'yes', - fn () => false - ); - $this->io->writeln('Result: '.($result ? 'true' : 'false')); - } - - /** - * Test getOptionOrPrompt with different return types. - */ - private function testGetOptionOrPromptTypes(): void - { - $expected = $this->testArgs[0] ?? 'default'; - - $result = $this->io->getOptionOrPrompt( - 'name', - fn () => $expected - ); - - if (is_bool($result)) { - $this->io->writeln('Result: '.($result ? 'true' : 'false')); - } elseif (is_array($result)) { - $this->io->writeln('Result: '.json_encode($result)); - } else { - $this->io->writeln("Result: {$result}"); - } - } - - /** - * Test getValidatedOptionOrPrompt with valid input. - */ - private function testGetValidatedOptionOrPromptValid(): void - { - $result = $this->io->getValidatedOptionOrPrompt( - 'name', - fn ($validate) => $this->io->promptText(label: 'Name:', validate: $validate), - fn ($value) => trim((string) $value) === '' ? 'Cannot be empty' : null - ); - - if ($result === null) { - $this->io->writeln('Result: null'); - } else { - $this->io->writeln("Result: {$result}"); - } - } - - /** - * Test getValidatedOptionOrPrompt with invalid input. - */ - private function testGetValidatedOptionOrPromptInvalid(): void - { - $result = $this->io->getValidatedOptionOrPrompt( - 'name', - fn ($validate) => $this->io->promptText(label: 'Name:', validate: $validate), - fn ($value) => 'Always invalid' - ); - - $this->io->writeln('Result: '.($result ?? 'null')); - } - - /** - * Test promptSpin wrapper. - */ - private function testPromptSpinWrapper(): void - { - $result = $this->io->promptSpin( - fn () => 'success', - 'Testing...' - ); - - $this->io->writeln("Spin result: {$result}"); - } - - /** - * Test promptText wrapper. - */ - private function testPromptTextWrapper(): void - { - $this->io->promptText('Test:', required: false); - } - - /** - * Test promptPassword wrapper. - */ - private function testPromptPasswordWrapper(): void - { - $this->io->promptPassword('Test:', required: false); - } - - /** - * Test promptConfirm wrapper. - */ - private function testPromptConfirmWrapper(): void - { - $this->io->promptConfirm('Test:'); - } - - /** - * Test promptPause wrapper. - */ - private function testPromptPauseWrapper(): void - { - $this->io->promptPause('Test'); - } - - /** - * Test promptSelect wrapper. - */ - private function testPromptSelectWrapper(): void - { - $this->io->promptSelect('Test:', ['a', 'b'], default: 'a'); - } - - /** - * Test promptMultiselect wrapper. - */ - private function testPromptMultiselectWrapper(): void - { - $this->io->promptMultiselect('Test:', ['a', 'b']); - } - - /** - * Test promptSuggest wrapper. - */ - private function testPromptSuggestWrapper(): void - { - $this->io->promptSuggest('Test:', ['a', 'b'], required: false); - } - - /** - * Test promptSearch wrapper. - */ - private function testPromptSearchWrapper(): void - { - $this->io->promptSearch('Test:', fn ($q) => ['a', 'b']); - } -} diff --git a/tests/Unit/ArchitectureTest.php b/tests/Integration/ArchitectureTest.php similarity index 100% rename from tests/Unit/ArchitectureTest.php rename to tests/Integration/ArchitectureTest.php diff --git a/tests/Integration/Console/HelloCommandTest.php b/tests/Integration/Console/HelloCommandTest.php deleted file mode 100644 index fde5996e..00000000 --- a/tests/Integration/Console/HelloCommandTest.php +++ /dev/null @@ -1,67 +0,0 @@ -build(HelloCommand::class); - return new CommandTester($command); -} - -// -// Integration tests -// ------------------------------------------------------------------------------- - -describe('HelloCommand', function () { - beforeEach(function () { - $this->originals = []; - - foreach (['USER', 'USERNAME'] as $key) { - - $value = getenv($key); - - $this->originals[$key] = $value === false ? null : $value; - - setEnv($key, null); - - } - }); - - it('displays correct greeting based on environment', function (array $env, string $expectedMessage) { - // ARRANGE - foreach ($env as $key => $value) { - setEnv($key, $value); - } - $tester = createCommandTester(); - - // ACT - $exitCode = $tester->execute([]); - - // ASSERT - expect($exitCode)->toBe(Command::SUCCESS) - ->and($tester->getDisplay())->toContain($expectedMessage); - })->with([ - 'USER variable' => [['USER' => 'johndoe'], 'Hello johndoe!'], - 'USERNAME variable' => [['USERNAME' => 'janedoe'], 'Hello janedoe!'], - 'USER wins over USERNAME' => [['USER' => 'primary', 'USERNAME' => 'secondary'], 'Hello primary!'], - 'defaults when empty' => [[], 'Hello there!'], - ]); - - afterEach(function () { - foreach ($this->originals as $key => $value) { - setEnv($key, $value); - } - }); -}); diff --git a/tests/Integration/Console/Server/ServerAddCommandTest.php b/tests/Integration/Console/Server/ServerAddCommandTest.php deleted file mode 100644 index 3a12ca54..00000000 --- a/tests/Integration/Console/Server/ServerAddCommandTest.php +++ /dev/null @@ -1,206 +0,0 @@ -build(ServerAddCommand::class); - return new CommandTester($command); -} - -// -// Integration tests -// ------------------------------------------------------------------------------- - -describe('ServerAddCommand', function () { - // - // Success Scenarios - // ------------------------------------------------------------------------------- - - it('adds server with all options provided non-interactively', function () { - // ARRANGE - $tester = createServerAddCommandTester(); - - // ACT - Provide all options for fully non-interactive execution - ob_start(); - $exitCode = $tester->execute([ - '--name' => 'production-web', - '--host' => '192.168.1.100', - '--port' => '2222', - '--username' => 'deployer', - '--private-key-path' => '~/.ssh/prod_key', - ]); - ob_end_clean(); - - // ASSERT - $output = $tester->getDisplay(); - expect($exitCode)->toBe(Command::SUCCESS) - ->and($output)->toContain('✓') - ->and($output)->toContain('Server added successfully') - ->and($output)->toContain('Run non-interactively:') - ->and($output)->toContain('server:add') - ->and($output)->toContain('production-web') - ->and($output)->toContain('192.168.1.100'); - }); - - it('adds server with minimal options using defaults', function () { - // ARRANGE - $tester = createServerAddCommandTester(); - - // ACT - Provide all required options to avoid prompting - ob_start(); - $exitCode = $tester->execute([ - '--name' => 'web1', - '--host' => '192.168.1.1', - '--port' => '22', - '--username' => 'root', - '--private-key-path' => '', - ]); - ob_end_clean(); - - // ASSERT - $output = $tester->getDisplay(); - expect($exitCode)->toBe(Command::SUCCESS) - ->and($output)->toContain('Server added successfully') - ->and($output)->toContain('Port:') - ->and($output)->toContain('22') - ->and($output)->toContain('User:') - ->and($output)->toContain('root'); - }); - - // - // Error Scenarios - // ------------------------------------------------------------------------------- - - it('rejects invalid host with helpful error message', function (string $invalidHost) { - // ARRANGE - $tester = createServerAddCommandTester(); - - // ACT - ob_start(); - $exitCode = $tester->execute([ - '--name' => 'test', - '--host' => $invalidHost, - '--port' => '22', - '--username' => 'root', - '--private-key-path' => '', - ]); - ob_end_clean(); - - // ASSERT - $output = $tester->getDisplay(); - expect($exitCode)->toBe(Command::FAILURE) - ->and($output)->toContain('✗') - ->and($output)->toContain('valid'); - })->with([ - 'underscore' => ['server_name'], - 'spaces' => ['my server'], - 'special chars' => ['server!@#'], - ]); - - it('rejects invalid port with helpful error message', function (string $invalidPort) { - // ARRANGE - $tester = createServerAddCommandTester(); - - // ACT - ob_start(); - $exitCode = $tester->execute([ - '--name' => 'test', - '--host' => '192.168.1.1', - '--port' => $invalidPort, - '--username' => 'root', - '--private-key-path' => '', - ]); - ob_end_clean(); - - // ASSERT - $output = $tester->getDisplay(); - expect($exitCode)->toBe(Command::FAILURE) - ->and($output)->toContain('✗') - ->and($output)->toMatch('/Port must be|between 1 and 65535/'); - })->with([ - 'zero' => ['0'], - 'negative' => ['-1'], - 'too high' => ['65536'], - 'way too high' => ['100000'], - ]); - - it('prevents duplicate server names', function () { - // ARRANGE - $tester = createServerAddCommandTester(); - - // ACT - Add first server (capture output) - ob_start(); - $tester->execute([ - '--name' => 'duplicate-name', - '--host' => '192.168.1.1', - '--port' => '22', - '--username' => 'root', - '--private-key-path' => '', - ]); - ob_end_clean(); - - // ACT - Try to add duplicate (capture output) - ob_start(); - $exitCode = $tester->execute([ - '--name' => 'duplicate-name', - '--host' => '192.168.1.2', - '--port' => '22', - '--username' => 'root', - '--private-key-path' => '', - ]); - ob_end_clean(); - - // ASSERT - $output = $tester->getDisplay(); - expect($exitCode)->toBe(Command::FAILURE) - ->and($output)->toContain('✗') - ->and($output)->toContain('already exists') - ->and($output)->toContain('duplicate-name'); - }); - - it('prevents duplicate server hosts', function () { - // ARRANGE - $tester = createServerAddCommandTester(); - - // ACT - Add first server (capture output) - ob_start(); - $tester->execute([ - '--name' => 'server-one', - '--host' => '192.168.1.100', - '--port' => '22', - '--username' => 'root', - '--private-key-path' => '', - ]); - ob_end_clean(); - - // ACT - Try to add different server with same host (capture output) - ob_start(); - $exitCode = $tester->execute([ - '--name' => 'server-two', - '--host' => '192.168.1.100', - '--port' => '22', - '--username' => 'root', - '--private-key-path' => '', - ]); - ob_end_clean(); - - // ASSERT - $output = $tester->getDisplay(); - expect($exitCode)->toBe(Command::FAILURE) - ->and($output)->toContain('✗') - ->and($output)->toContain('already used by server') - ->and($output)->toContain('server-one'); - }); -}); diff --git a/tests/Integration/Console/Server/ServerDeleteCommandTest.php b/tests/Integration/Console/Server/ServerDeleteCommandTest.php deleted file mode 100644 index e48f58e0..00000000 --- a/tests/Integration/Console/Server/ServerDeleteCommandTest.php +++ /dev/null @@ -1,135 +0,0 @@ - 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); - return new CommandTester($command); -} - -// -// Integration tests -// ------------------------------------------------------------------------------- - -describe('ServerDeleteCommand', function () { - // - // Success Scenarios - // ------------------------------------------------------------------------------- - - it('deletes server with server option non-interactively', function () { - // ARRANGE - $existingServers = [ - new ServerDTO('web1', '192.168.1.1', 22, 'root', null), - new ServerDTO('web2', '192.168.1.2', 22, 'root', null), - ]; - $tester = createServerDeleteCommandTester($existingServers); - - // ACT - $exitCode = $tester->execute([ - '--server' => 'web1', - '--yes' => true, - ]); - - // ASSERT - $output = $tester->getDisplay(); - expect($exitCode)->toBe(Command::SUCCESS) - ->and($output)->toContain('✓') - ->and($output)->toContain("Server 'web1' deleted successfully") - ->and($output)->toContain('Run non-interactively:') - ->and($output)->toContain('server:delete'); - }); - - // - // Error Scenarios - // ------------------------------------------------------------------------------- - - it('fails when deleting non-existent server', function () { - // ARRANGE - $existingServers = [ - new ServerDTO('existing', '192.168.1.1', 22, 'root', null), - ]; - $tester = createServerDeleteCommandTester($existingServers); - - // ACT - $exitCode = $tester->execute([ - '--server' => 'non-existent', - '--yes' => true, - ]); - - // ASSERT - $output = $tester->getDisplay(); - expect($exitCode)->toBe(Command::FAILURE) - ->and($output)->toContain('✗') - ->and($output)->toContain("Server 'non-existent' not found"); - }); - - it('handles empty inventory gracefully', function () { - // ARRANGE - $tester = createServerDeleteCommandTester([]); - - // ACT - $exitCode = $tester->execute([]); - - // ASSERT - $output = $tester->getDisplay(); - expect($exitCode)->toBe(Command::SUCCESS) - ->and($output)->toContain('⚠') - ->and($output)->toContain('No servers found in inventory') - ->and($output)->toContain('server:add'); - }); - - it('prevents deletion when server has sites', function () { - // ARRANGE - $existingServers = [ - new ServerDTO('web1', '192.168.1.1', 22, 'root', null), - ]; - $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 - $exitCode = $tester->execute([ - '--server' => 'web1', - '--yes' => true, - ]); - - // ASSERT - $output = $tester->getDisplay(); - 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 deleted file mode 100644 index d73c5627..00000000 --- a/tests/Integration/Console/Server/ServerListCommandTest.php +++ /dev/null @@ -1,186 +0,0 @@ - $existingServers - * @param array $existingSites - */ -function createServerListCommandTester(array $existingServers = [], array $existingSites = []): CommandTester -{ - // 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); - return new CommandTester($command); -} - -// -// Integration tests -// ------------------------------------------------------------------------------- - -describe('ServerListCommand', function () { - // - // Success Scenarios - // ------------------------------------------------------------------------------- - - it('lists servers with full details', function () { - // ARRANGE - $existingServers = [ - new ServerDTO('web1', '192.168.1.1', 22, 'root', null), - new ServerDTO('web2', '192.168.1.2', 2222, 'deployer', '~/.ssh/custom'), - new ServerDTO('database', '10.0.0.5', 22, 'admin', null), - ]; - $tester = createServerListCommandTester($existingServers); - - // ACT - $exitCode = $tester->execute([]); - - // ASSERT - $output = $tester->getDisplay(); - expect($exitCode)->toBe(Command::SUCCESS) - ->and($output)->toContain('▸') - ->and($output)->toContain('All Servers') - ->and($output)->toContain('web1') - ->and($output)->toContain('192.168.1.1') - ->and($output)->toContain('web2') - ->and($output)->toContain('192.168.1.2') - ->and($output)->toContain('2222') - ->and($output)->toContain('deployer') - ->and($output)->toContain('database') - ->and($output)->toContain('10.0.0.5'); - }); - - // - // Edge Cases - // ------------------------------------------------------------------------------- - - it('handles empty inventory gracefully', function () { - // ARRANGE - $tester = createServerListCommandTester([]); - - // ACT - $exitCode = $tester->execute([]); - - // ASSERT - $output = $tester->getDisplay(); - expect($exitCode)->toBe(Command::SUCCESS) - ->and($output)->toContain('⚠') - ->and($output)->toContain('No servers found in inventory') - ->and($output)->toContain('server:add') - ->and($output)->not->toContain('All Servers'); - }); - - it('displays SSH key path correctly', function (?string $keyPath, array $expectedOutput) { - // ARRANGE - $existingServers = [ - new ServerDTO('test-server', '192.168.1.1', 22, 'root', $keyPath), - ]; - $tester = createServerListCommandTester($existingServers); - - // ACT - $exitCode = $tester->execute([]); - - // ASSERT - $output = $tester->getDisplay(); - expect($exitCode)->toBe(Command::SUCCESS) - ->and($output)->toContain('Key:'); - - 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('web1', '192.168.1.1', 22, 'root', null), - new ServerDTO('web2', '192.168.1.2', 22, 'root', null), - ]; - $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([]); - - // ASSERT - $output = $tester->getDisplay(); - expect($exitCode)->toBe(Command::SUCCESS) - ->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('displays no sites section when server has no sites', function () { - // ARRANGE - $existingServers = [ - new ServerDTO('web1', '192.168.1.1', 22, 'root', null), - ]; - $tester = createServerListCommandTester($existingServers, []); - - // ACT - $exitCode = $tester->execute([]); - - // ASSERT - $output = $tester->getDisplay(); - expect($exitCode)->toBe(Command::SUCCESS) - ->and($output)->toContain('web1') - ->and($output)->not->toContain('Sites:'); - }); -}); diff --git a/tests/Integration/Console/Site/SiteAddCommandTest.php b/tests/Integration/Console/Site/SiteAddCommandTest.php deleted file mode 100644 index f773a47f..00000000 --- a/tests/Integration/Console/Site/SiteAddCommandTest.php +++ /dev/null @@ -1,244 +0,0 @@ - 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 deleted file mode 100644 index 155eee05..00000000 --- a/tests/Integration/Console/Site/SiteDeleteCommandTest.php +++ /dev/null @@ -1,106 +0,0 @@ - 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 deleted file mode 100644 index 8a81b6f9..00000000 --- a/tests/Integration/Console/Site/SiteListCommandTest.php +++ /dev/null @@ -1,123 +0,0 @@ - $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/Integration/SymfonyAppTest.php b/tests/Integration/SymfonyAppTest.php deleted file mode 100644 index 4ea25e93..00000000 --- a/tests/Integration/SymfonyAppTest.php +++ /dev/null @@ -1,210 +0,0 @@ -build(SymfonyApp::class); - $name = $app->getName(); - $version = $app->getVersion(); - $command = $app->find('hello'); - $commands = $app->all(); - $commandNames = array_keys($commands); - - // ASSERT - SymfonyApp setup - expect(strlen($name))->toBeGreaterThan(0) - ->and(strlen($version))->toBeGreaterThan(0) - ->and($app->getHelp())->toBe('') - ->and($commandNames)->toContain('hello'); - - // ASSERT - Command can execute successfully - expect($command->getName())->toBe('hello'); - }); - - // - // Banner display - - it('displays banner with branding elements', function (array $expectedBannerElements) { - // ARRANGE - $container = new Container(); - $app = $container->build(SymfonyApp::class); - - $input = new ArrayInput(['command' => 'list']); - - $output = new BufferedOutput(); - $output->setDecorated(false); - - $version = $app->getVersion(); - - // ACT - $app->doRun($input, $output); - $outputContent = $output->fetch(); - - // ASSERT - Banner elements verification - foreach ($expectedBannerElements as $element) { - if ($element === 'VERSION_LINE') { - expect($outputContent)->toContain('─┴┘└─┘┴ ┴─┘└─┘ ┴ └─┘┴└─PHP '.$version); - } else { - expect($outputContent)->toContain($element); - } - } - })->with([ - 'banner' => [[ - '┌┬┐┌─┐┌─┐┬ ┌─┐┬ ┬┌─┐┬─┐', // ASCII art line 1 - ' ││├┤ ├─┘│ │ │└┬┘├┤ ├┬┘', // ASCII art line 2 - 'VERSION_LINE', // Dynamic version line - 'The Server & Site Deployment Tool for PHP', - ]] - ]); - - // - // Command Execution - - it('executes valid commands successfully', function (string $command, array $expectedContent) { - // ARRANGE - $container = new Container(); - $app = $container->build(SymfonyApp::class); - - $input = new ArrayInput(['command' => $command]); - - $output = new BufferedOutput(); - $output->setDecorated(false); - - // ACT - $exitCode = $app->doRun($input, $output); - $outputContent = $output->fetch(); - - // ASSERT - expect($exitCode)->toBe(0); - foreach ($expectedContent as $content) { - expect($outputContent)->toContain($content); - } - })->with([ - 'hello command' => ['hello', ['Hello', '┌┬┐┌─┐┌─┐', 'Environment:', 'Inventory:']], // Banner + greeting + status - 'list command' => ['list', ['Available commands', '┌┬┐┌─┐┌─┐']], // Banner + command list - ]); - - it('handles invalid commands gracefully', function (string $command, string $expectedError) { - // ARRANGE - $container = new Container(); - $app = $container->build(SymfonyApp::class); - - $input = new ArrayInput(['command' => $command]); - - $output = new BufferedOutput(); - $output->setDecorated(false); - - // ACT & ASSERT - try { - $exitCode = $app->doRun($input, $output); - $outputContent = $output->fetch(); - } catch (CommandNotFoundException $e) { - $exitCode = 1; - $outputContent = $e->getMessage(); - } - - expect($exitCode)->toBe(1) - ->and($outputContent)->toContain($expectedError); - })->with([ - 'non-existent command' => ['non-existent-command', 'Command "non-existent-command" is not defined'], - 'typo command' => ['helo', 'Command "helo" is not defined'], - ]); - - it('maintains state consistency across multiple executions', function () { - // ARRANGE - $container = new Container(); - $app = $container->build(SymfonyApp::class); - - $input = new ArrayInput(['command' => 'list']); - - $output = new BufferedOutput(); - $output->setDecorated(false); - - // ACT - Multiple runs - $exitCode1 = $app->doRun($input, $output); - $output1 = $output->fetch(); - - $exitCode2 = $app->doRun($input, $output); - $output2 = $output->fetch(); - - // ASSERT - Consistent behavior - expect($exitCode1)->toBe(Command::SUCCESS) - ->and($exitCode2)->toBe(Command::SUCCESS) - ->and($output1)->toContain('┌┬┐┌─┐┌─┐') - ->and($output2)->toContain('┌┬┐┌─┐┌─┐'); - }); - - // - // Custom Input Definition - - it('exposes only custom-defined input options', function () { - // ARRANGE - $container = new Container(); - $app = $container->build(SymfonyApp::class); - - // ACT - $definition = $app->getDefinition(); - $availableOptions = array_keys($definition->getOptions()); - - // ASSERT - expect($availableOptions)->toContain('help') - ->and($availableOptions)->toContain('version') - ->and($availableOptions)->toContain('ansi') - ->and($availableOptions)->not->toContain('quiet') - ->and($availableOptions)->not->toContain('verbose') - ->and($availableOptions)->not->toContain('no-interaction'); - }); - - it('exits early when --version flag is provided', function () { - // ARRANGE - $container = new Container(); - $app = $container->build(SymfonyApp::class); - - $input = new ArrayInput(['--version' => true]); - - $output = new BufferedOutput(); - $output->setDecorated(false); - - // ACT - $exitCode = $app->doRun($input, $output); - $outputContent = $output->fetch(); - - // ASSERT - expect($exitCode)->toBe(Command::SUCCESS) - ->and($outputContent)->toContain('┌┬┐┌─┐┌─┐') - ->and($outputContent)->not->toContain('Available commands'); - }); - - it('always displays banner regardless of output settings', function () { - // ARRANGE - $container = new Container(); - $app = $container->build(SymfonyApp::class); - - $input = new ArrayInput(['command' => 'list']); - - $output = new BufferedOutput(); - $output->setDecorated(false); - - // ACT - $app->doRun($input, $output); - $outputContent = $output->fetch(); - - // ASSERT - expect($outputContent)->toContain('┌┬┐┌─┐┌─┐') - ->and($outputContent)->toContain('The Server & Site Deployment Tool for PHP'); - }); - -}); diff --git a/tests/TestHelpers.php b/tests/TestHelpers.php deleted file mode 100644 index 11be533d..00000000 --- a/tests/TestHelpers.php +++ /dev/null @@ -1,408 +0,0 @@ -readFile('file'); // Throws IOException - */ - function mockFilesystem( - bool $exists = true, - string $content = '', - bool $throwOnRead = false, - bool $throwOnMkdir = false, - bool $throwOnDump = false, - string $initialPath = 'inventory.yml' - ): Filesystem { - return new MockFilesystem($exists, $content, $throwOnRead, $throwOnMkdir, $throwOnDump, $initialPath); - } -} - -if (!function_exists('mockFilesystemService')) { - /** - * Create a FilesystemService with a mock Filesystem for testing. - * - * @example - * $service = mockFilesystemService( - * fileExists: true, - * fileContent: 'content', - * filePath: 'data.txt' - * ); - * $service->readFile('data.txt'); // 'content' - */ - function mockFilesystemService( - bool $fileExists = true, - string $fileContent = '', - bool $throwOnRead = false, - bool $throwOnMkdir = false, - bool $throwOnWrite = false, - string $filePath = 'test.txt' - ): FilesystemService { - $mockFs = mockFilesystem($fileExists, $fileContent, $throwOnRead, $throwOnMkdir, $throwOnWrite, $filePath); - return new FilesystemService($mockFs); - } -} - -// -// Service Layer Mocks -// ------------------------------------------------------------------------------- - -if (!function_exists('mockEnvService')) { - /** - * Create a mock EnvService for testing with configurable filesystem behavior. - * - * @example - * // Service with valid .env file - * $service = mockEnvService(fileExists: true, fileContent: 'API_KEY=secret'); - * $service->loadEnvFile(); - * $service->get('API_KEY'); // 'secret' - * - * @example - * // Test missing .env file handling - * $service = mockEnvService(fileExists: false); - * $service->loadEnvFile(); // Handles gracefully - */ - function mockEnvService( - bool $fileExists = true, - string $fileContent = 'API_KEY=test_value', - bool $throwOnRead = false - ): EnvService { - $mockFs = mockFilesystem($fileExists, $fileContent, $throwOnRead, false, false, '.env'); - $filesystemService = new FilesystemService($mockFs); - return new EnvService($filesystemService, new Dotenv()); - } -} - -if (!function_exists('mockInventoryService')) { - /** - * Create a mock InventoryService for tests with a configurable in-memory inventory file. - * - * 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. - * - * @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, - array|string $data = [], - bool $throwOnRead = false, - bool $throwOnWrite = false - ): InventoryService { - // Convert array data to YAML - $fileContent = match (true) { - is_array($data) && !empty($data) => Yaml::dump($data, 2, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE), - is_array($data) => '', - default => $data, - }; - - $mockFs = mockFilesystem($fileExists, $fileContent, $throwOnRead, false, $throwOnWrite, 'inventory.yml'); - $filesystemService = new FilesystemService($mockFs); - return new InventoryService($filesystemService); - } -} - -if (!function_exists('mockProcessService')) { - /** - * Creates a ProcessService configured for tests. - * - * 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()). - * - * @return ProcessService A ProcessService backed by a FilesystemService using a real Filesystem. - */ - function mockProcessService(): ProcessService - { - $filesystemService = new FilesystemService(new Filesystem()); - return new ProcessService($filesystemService); - } -} - -if (!function_exists('mockSSHService')) { - /** - * Create an SSHService for testing with mocked dependencies. - * - * Returns a real SSHService instance with mocked filesystem and env dependencies. - * Use mockSSHServiceWithBehavior() for simulating connection success/failure. - * - * @example - * $ssh = mockSSHService(); - * // Use for testing code that depends on SSHService without network calls - */ - function mockSSHService(): SSHService - { - $envService = mockEnvService(fileExists: true); - $filesystemService = new FilesystemService(new Filesystem()); - - return new SSHService($envService, $filesystemService); - } -} - -if (!function_exists('mockSSHServiceWithBehavior')) { - /** - * Create a mock SSHService that simulates connection success/failure. - * - * @param bool $canConnect Whether SSH connection should succeed (true) or fail (false) - * - * @example - * // Simulate successful connection - * $ssh = mockSSHServiceWithBehavior(canConnect: true); - * $ssh->assertCanConnect('host', 22, 'user'); // No exception - * - * @example - * // Simulate connection failure - * $ssh = mockSSHServiceWithBehavior(canConnect: false); - * $ssh->assertCanConnect('host', 22, 'user'); // Throws RuntimeException - */ - function mockSSHServiceWithBehavior(bool $canConnect = true): SSHService - { - return new MockSSHService($canConnect); - } -} - -if (!function_exists('mockIOService')) { - /** - * Create an IOService for testing. - * - * Returns a plain IOService instance. Must call initialize() before using I/O methods. - * Prompts will run in non-interactive mode during tests. - * - * @example - * $io = mockIOService(); - * $io->initialize($command, $input, $output); - */ - function mockIOService(): IOService - { - return new IOService(); - } -} - -if (!function_exists('mockVersionService')) { - /** - * Create a VersionService configured for tests with an optional package name and fallback version. - * - * Uses a real Filesystem and ProcessService because version resolution may perform git/directory checks. - * - * @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()); - $proc = new ProcessService($filesystemService); - - return match (true) { - $packageName !== null && $fallback !== null => new VersionService($proc, $filesystemService, $packageName, $fallback), - $packageName !== null => new VersionService($proc, $filesystemService, $packageName), - default => new VersionService($proc, $filesystemService), - }; - } -} - -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 -// ------------------------------------------------------------------------------- - -if (!function_exists('mockServerRepository')) { - /** - * Create a ServerRepository preloaded with inventory data for use in tests. - * - * The returned repository has its inventory loaded from a mocked InventoryService and is ready for immediate use. - * - * @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, - array|string $data = [], - bool $throwOnRead = false, - bool $throwOnWrite = false - ): ServerRepository { - $inventory = mockInventoryService($fileExists, $data, $throwOnRead, $throwOnWrite); - $inventory->loadInventoryFile(); - - $repository = new ServerRepository(); - $repository->loadInventory($inventory); - - return $repository; - } -} - -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 -// ------------------------------------------------------------------------------- - -if (!function_exists('mockCommandContainer')) { - /** - * Create a Container with mocked dependencies for command testing. - * - * Returns a Container with sensible mock defaults for all BaseCommand dependencies. - * Override specific services by passing them as arguments. - * - * @example - * // Build command with default mocks - * $container = mockCommandContainer(); - * $command = $container->build(ServerListCommand::class); - * - * @example - * // Override SSH service for connection testing - * $ssh = mockSSHServiceWithBehavior(canConnect: false); - * $container = mockCommandContainer(ssh: $ssh); - * $command = $container->build(ServerAddCommand::class); - * - * @example - * // Override inventory data for pre-populated servers - * $container = mockCommandContainer( - * inventoryData: ['servers' => ['web1' => ['host' => '192.168.1.1']]] - * ); - * $command = $container->build(ServerListCommand::class); - */ - function mockCommandContainer( - // Base services (alphabetical order) - ?EnvService $env = null, - ?GitService $git = null, - ?InventoryService $inventory = null, - ?IOService $io = null, - ?ProcessService $proc = 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, - array|string $inventoryData = [] - ): Container { - $container = new Container(); - - // Build or use provided services (matches BaseCommand constructor order) - $env ??= mockEnvService($envFileExists, $envContent); - $git ??= mockGitService(); - $inventory ??= mockInventoryService($inventoryFileExists, $inventoryData); - $io ??= mockIOService(); - $proc ??= mockProcessService(); - $servers ??= mockServerRepository($inventoryFileExists, $inventoryData); - $sites ??= mockSiteRepository($inventoryFileExists, $inventoryData); - $ssh ??= mockSSHService(); - - // 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); - $container->bind(ServerRepository::class, $servers); - $container->bind(SiteRepository::class, $sites); - $container->bind(SSHService::class, $ssh); - - return $container; - } -} diff --git a/tests/Unit/ContainerTest.php b/tests/Unit/ContainerTest.php deleted file mode 100644 index f366b729..00000000 --- a/tests/Unit/ContainerTest.php +++ /dev/null @@ -1,189 +0,0 @@ -container = new Container(); - }); - - it('builds classes without dependencies', function () { - // ARRANGE & ACT - $simple = $this->container->build(SimpleService::class); - $noConstructor = $this->container->build(NoConstructorService::class); - - // ASSERT - expect($simple->getName())->toBe('simple') - ->and($noConstructor->getType())->toBe('no-constructor') - ->and($this->container->build(SimpleService::class))->not->toBe($simple); // New instances - }); - - it('resolves dependencies recursively', function () { - // ARRANGE & ACT - $service = $this->container->build(ServiceWithMultipleDeps::class); - - // ASSERT - expect($service->getSimple()->getName())->toBe('simple') - ->and($service->getComplex()->getDependency()->getName())->toBe('simple'); - }); - - it('uses default parameter values', function () { - // ARRANGE & ACT - $service = $this->container->build(ServiceWithDefaults::class); - - // ASSERT - expect($service->getName())->toBe('default'); - }); - - it('uses default values when class dependencies fail to resolve', function () { - // ARRANGE & ACT - $service = $this->container->build(ServiceWithOptionalClassDep::class); - - // ASSERT - expect($service->hasDep())->toBeFalse(); - }); - - it('detects circular dependencies', function () { - // ARRANGE & ACT & ASSERT - expect(fn () => $this->container->build(CircularA::class)) - ->toThrow(\RuntimeException::class, 'Circular dependency detected'); - }); - - it('throws exceptions for invalid classes', function (string $className, string $errorPattern) { - // ARRANGE & ACT & ASSERT - expect(fn () => $this->container->build($className)) - ->toThrow(\RuntimeException::class, $errorPattern); - })->with([ - 'non-existent class' => ['NonExistentClass', 'does not exist'], - 'interface' => [TestInterface::class, 'does not exist'], // Interfaces don't pass class_exists() - 'abstract class' => [AbstractClass::class, 'not instantiable'], - 'private constructor' => [PrivateConstructor::class, 'not instantiable'], - 'unresolvable scalar parameter' => [ServiceWithScalarParam::class, 'Cannot resolve parameter [required] in [Bigpixelrocket\\DeployerPHP\\Tests\\Fixtures\\ServiceWithScalarParam]'], - ]); - - it('cleans up state after errors', function () { - // ARRANGE - try { - $this->container->build(CircularA::class); - } catch (\RuntimeException) { - // Expected - } - - // ACT - Should work fine after error - $result = $this->container->build(SimpleService::class); - - // ASSERT - expect($result->getName())->toBe('simple'); - }); - - it('resolves union types by trying each class arm', function () { - // ARRANGE & ACT - $service = $this->container->build(ServiceWithUnionType::class); - - // ASSERT - expect($service->getServiceType())->toBe('simple'); // First resolvable arm (SimpleService) - }); - - it('falls back to defaults for intersection types', function () { - // ARRANGE & ACT - $service = $this->container->build(ServiceWithIntersectionType::class); - - // ASSERT - expect($service->hasData())->toBeFalse(); // Uses default null value - }); - - it('includes declaring class in dependency resolution errors', function () { - // ARRANGE & ACT & ASSERT - expect(fn () => $this->container->build(ServiceWithUnresolvableDependency::class)) - ->toThrow(\RuntimeException::class, 'Cannot resolve dependency [Bigpixelrocket\\DeployerPHP\\Tests\\Fixtures\\AbstractClass] for parameter [dependency] in [Bigpixelrocket\\DeployerPHP\\Tests\\Fixtures\\ServiceWithUnresolvableDependency]'); - }); - - it('detects circular dependencies in union types', function () { - // ARRANGE & ACT & ASSERT - expect(fn () => $this->container->build(ServiceWithUnionAndCircular::class)) - ->toThrow(\RuntimeException::class, 'Circular dependency detected'); - }); - - it('binds instances for testing', function () { - // ARRANGE - $mockService = new SimpleService(); - - // ACT - $result = $this->container->bind(SimpleService::class, $mockService); - $retrieved = $this->container->build(SimpleService::class); - - // ASSERT - expect($result)->toBe($this->container) // Fluent interface - ->and($retrieved)->toBe($mockService); // Returns bound instance - }); - - it('uses bound instances in dependency resolution', function () { - // ARRANGE - $mockSimple = new SimpleService(); - $this->container->bind(SimpleService::class, $mockSimple); - - // ACT - Build ServiceWithMultipleDeps which depends on SimpleService - $service = $this->container->build(ServiceWithMultipleDeps::class); - - // ASSERT - Should use bound instance - expect($service->getSimple())->toBe($mockSimple) - ->and($service->getComplex()->getDependency())->toBe($mockSimple); - }); - - it('binds override auto-wiring', function () { - // ARRANGE - $auto = $this->container->build(SimpleService::class); - $mockService = new SimpleService(); - - // ACT - $this->container->bind(SimpleService::class, $mockService); - $bound = $this->container->build(SimpleService::class); - - // ASSERT - expect($bound)->not->toBe($auto) - ->and($bound)->toBe($mockService); - }); - - it('returns same bound instance on multiple builds (singleton behavior)', function () { - // ARRANGE - $mock = new SimpleService(); - $this->container->bind(SimpleService::class, $mock); - - // ACT - Build twice - $first = $this->container->build(SimpleService::class); - $second = $this->container->build(SimpleService::class); - - // ASSERT - Same bound instance both times - expect($first)->toBe($mock) - ->and($second)->toBe($mock) - ->and($first)->toBe($second); - }); -}); diff --git a/tests/Unit/Contracts/BaseCommandTest.php b/tests/Unit/Contracts/BaseCommandTest.php deleted file mode 100644 index 89ecbdeb..00000000 --- a/tests/Unit/Contracts/BaseCommandTest.php +++ /dev/null @@ -1,108 +0,0 @@ -setName('test-command')->setDescription('Test command for BaseCommand testing'); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $result = parent::execute($input, $output); - $this->io->writeln('Test command executed successfully'); - return $result; - } -} - -// -// Unit tests -// ------------------------------------------------------------------------------- - -describe('BaseCommand', function () { - it('constructs with dependencies and registers custom options', function () { - // ARRANGE - $container = mockCommandContainer(); - $command = $container->build(TestableBaseCommand::class); - - // ASSERT - expect($command->getName())->toBe('test-command') - ->and($command->getDefinition()->hasOption('env'))->toBeTrue() - ->and($command->getDefinition()->hasOption('inventory'))->toBeTrue() - ->and($command->getDefinition()->getOption('env')->getDescription()) - ->toContain('Custom path to .env file') - ->and($command->getDefinition()->getOption('inventory')->getDescription()) - ->toContain('Custom path to inventory.yml file'); - }); - - it('executes with proper env and inventory status output', function (bool $hasEnvFile, string $expectedEnvMessage) { - // ARRANGE - $container = mockCommandContainer(envFileExists: $hasEnvFile); - $command = $container->build(TestableBaseCommand::class); - $tester = new CommandTester($command); - - // ACT - $exitCode = $tester->execute([]); - $output = $tester->getDisplay(); - - // ASSERT - expect($exitCode)->toBe(Command::SUCCESS) - ->and($output)->toContain('Environment:') - ->and($output)->toContain('Inventory:') - ->and($output)->toContain($expectedEnvMessage) - ->and($output)->toContain('Reading inventory from') - ->and($output)->toContain('Test command executed successfully'); - })->with([ - '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/ServerDTOTest.php b/tests/Unit/DTOs/ServerDTOTest.php deleted file mode 100644 index 249ee863..00000000 --- a/tests/Unit/DTOs/ServerDTOTest.php +++ /dev/null @@ -1,35 +0,0 @@ -name)->toBe('production-web') - ->and($server->host)->toBe('192.168.1.100') - ->and($server->port)->toBe(2222) - ->and($server->username)->toBe('deployer') - ->and($server->privateKeyPath)->toBe('~/.ssh/custom_key'); - }); - - it('uses default values for optional properties', function () { - // ARRANGE & ACT - $server = new ServerDTO(name: 'test-server', host: '127.0.0.1'); - - // ASSERT - expect($server->port)->toBe(22) - ->and($server->username)->toBe('root') - ->and($server->privateKeyPath)->toBeNull(); - }); -}); diff --git a/tests/Unit/DTOs/SiteDTOTest.php b/tests/Unit/DTOs/SiteDTOTest.php deleted file mode 100644 index 156565ed..00000000 --- a/tests/Unit/DTOs/SiteDTOTest.php +++ /dev/null @@ -1,41 +0,0 @@ -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->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/ServerRepositoryTest.php b/tests/Unit/Repositories/ServerRepositoryTest.php deleted file mode 100644 index 2029cd72..00000000 --- a/tests/Unit/Repositories/ServerRepositoryTest.php +++ /dev/null @@ -1,163 +0,0 @@ - $repository->all()) - ->toThrow(\RuntimeException::class, 'Inventory not set'); - }); - - // - // CRUD Operations - // ------------------------------------------------------------------------------- - - it('handles complete CRUD lifecycle', function () { - // ARRANGE - $inventory = mockInventoryService(true, ['servers' => []]); - $inventory->loadInventoryFile(); - $repository = new ServerRepository(); - $repository->loadInventory($inventory); - - // ACT & ASSERT - Create - $server1 = new ServerDTO('web1', '192.168.1.1', 2222, 'deployer', '~/.ssh/key'); - $server2 = new ServerDTO('web2', '192.168.1.2'); - - $repository->create($server1); - $repository->create($server2); - - // ASSERT - Find by name - $found = $repository->findByName('web1'); - expect($found)->not->toBeNull() - ->and($found->name)->toBe('web1') - ->and($found->host)->toBe('192.168.1.1') - ->and($found->port)->toBe(2222) - ->and($found->username)->toBe('deployer') - ->and($found->privateKeyPath)->toBe('~/.ssh/key'); - - // ASSERT - Find by host - $foundByHost = $repository->findByHost('192.168.1.1'); - expect($foundByHost)->not->toBeNull() - ->and($foundByHost->name)->toBe('web1') - ->and($foundByHost->host)->toBe('192.168.1.1'); - - $foundByHost2 = $repository->findByHost('192.168.1.2'); - expect($foundByHost2)->not->toBeNull() - ->and($foundByHost2->name)->toBe('web2'); - - // ASSERT - Find returns null for missing - expect($repository->findByName('nonexistent'))->toBeNull(); - expect($repository->findByHost('10.0.0.1'))->toBeNull(); - - // ASSERT - All returns both servers - $all = $repository->all(); - expect($all)->toHaveCount(2) - ->and($all[0]->name)->toBe('web1') - ->and($all[1]->name)->toBe('web2'); - - // ACT & ASSERT - Delete - $repository->delete('web1'); - expect($repository->findByName('web1'))->toBeNull() - ->and($repository->all())->toHaveCount(1); - - // ASSERT - Delete nonexistent doesn't error - $repository->delete('never-existed'); - expect($repository->all())->toHaveCount(1); - }); - - it('prevents duplicate server creation', function () { - // ARRANGE - $inventory = mockInventoryService(true, ['servers' => [ - ['name' => 'existing', 'host' => '192.168.1.1', 'port' => 22, 'username' => 'root', 'privateKeyPath' => null], - ]]); - $inventory->loadInventoryFile(); - $repository = new ServerRepository(); - $repository->loadInventory($inventory); - - // ACT & ASSERT - expect(fn () => $repository->create(new ServerDTO('existing', '192.168.1.2'))) - ->toThrow(\RuntimeException::class, "Server 'existing' already exists"); - }); - - it('prevents duplicate server hosts', function () { - // ARRANGE - $inventory = mockInventoryService(true, ['servers' => [ - ['name' => 'existing', 'host' => '192.168.1.1', 'port' => 22, 'username' => 'root', 'privateKeyPath' => null], - ]]); - $inventory->loadInventoryFile(); - $repository = new ServerRepository(); - $repository->loadInventory($inventory); - - // ACT & ASSERT - expect(fn () => $repository->create(new ServerDTO('different-name', '192.168.1.1'))) - ->toThrow(\RuntimeException::class, "Host '192.168.1.1' is already used by server 'existing'"); - }); - - // - // Data Hydration Robustness - // ------------------------------------------------------------------------------- - - it('handles malformed inventory data gracefully', function (array $rawData, string $expectedName, string $expectedHost, int $expectedPort) { - // ARRANGE - $inventory = mockInventoryService(true, ['servers' => [$rawData]]); - $inventory->loadInventoryFile(); - $repository = new ServerRepository(); - $repository->loadInventory($inventory); - - // ACT - $servers = $repository->all(); - - // ASSERT - expect($servers)->toHaveCount(1) - ->and($servers[0]->name)->toBe($expectedName) - ->and($servers[0]->host)->toBe($expectedHost) - ->and($servers[0]->port)->toBe($expectedPort); - })->with([ - 'missing name' => [ - ['host' => '192.168.1.1', 'port' => 22, 'username' => 'root'], - '', '192.168.1.1', 22, - ], - 'missing host' => [ - ['name' => 'web1', 'port' => 22, 'username' => 'root'], - 'web1', '', 22, - ], - 'invalid port type' => [ - ['name' => 'web1', 'host' => '192.168.1.1', 'port' => 'not-a-number'], - 'web1', '192.168.1.1', 22, - ], - 'wrong name type' => [ - ['name' => 12345, 'host' => '192.168.1.1'], - '', '192.168.1.1', 22, - ], - ]); - - // - // Initialization Edge Cases - // ------------------------------------------------------------------------------- - - it('initializes empty array when servers key missing', function () { - // ARRANGE - $inventory = mockInventoryService(true, []); - $inventory->loadInventoryFile(); - $repository = new ServerRepository(); - - // ACT - $repository->loadInventory($inventory); - - // ASSERT - expect($repository->all())->toBeArray()->toBeEmpty(); - }); -}); diff --git a/tests/Unit/Repositories/SiteRepositoryTest.php b/tests/Unit/Repositories/SiteRepositoryTest.php deleted file mode 100644 index e19096bf..00000000 --- a/tests/Unit/Repositories/SiteRepositoryTest.php +++ /dev/null @@ -1,241 +0,0 @@ - $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 - $gitSite = new SiteDTO('example.com', 'git@github.com:user/repo.git', 'main', ['web1', 'web2']); - $localSite = new SiteDTO('local.dev', null, null, ['dev1']); - - $repository->create($gitSite); - $repository->create($localSite); - - // 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->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(); - - // ASSERT - All returns both sites - $all = $repository->all(); - expect($all)->toHaveCount(2) - ->and($all[0]->domain)->toBe('example.com') - ->and($all[1]->domain)->toBe('local.dev'); - - // 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"); - }); - - // - // 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) { - // 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); - - 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 (local site)' => [ - ['domain' => 'example.com', 'branch' => 'main', 'servers' => []], - 'example.com', null, 'main', [], - ], - 'missing branch (local site)' => [ - ['domain' => 'example.com', 'repo' => 'git@github.com:user/repo.git', 'servers' => []], - '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', [], - ], - '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/EnvServiceTest.php b/tests/Unit/Services/EnvServiceTest.php deleted file mode 100644 index 80b36341..00000000 --- a/tests/Unit/Services/EnvServiceTest.php +++ /dev/null @@ -1,95 +0,0 @@ - $service->loadEnvFile()) - ->toThrow(\RuntimeException::class, 'Error reading .env file from'); - } else { - $service->loadEnvFile(); - $status = $service->getEnvFileStatus(); - expect($status)->toMatch($expectedStatusPattern); - } - })->with([ - // No .env file exists - [false, '', false, '/^No \.env file found at .+$/'], - - // File exists but is empty (no variables) - [true, '', false, '/^No variables found in .+\.env$/'], - - // File exists and loads successfully with variables - [true, "API_KEY=test\nDB_HOST=localhost", false, '/^Reading variables from .+\.env$/'], - - // File exists but has read error (throws exception) - [true, 'API_KEY=test', true, null], - ]); - - it('resolves environment variables from multiple sources with correct precedence', function ($env, $fileContent, $fileError, $keys, $expected) { - // ARRANGE - foreach ($env as $key => $value) { - setEnv($key, $value); - } - $service = mockEnvService(!empty($fileContent), $fileContent, $fileError); - $service->loadEnvFile(); - - // ACT - $result = $service->get($keys, false); - - // ASSERT - expect($result)->toBe($expected); - - // CLEANUP - foreach (array_keys($env) as $key) { - setEnv($key, null); - } - })->with([ - // Single key scenarios - [[], 'API_KEY=from_file', false, 'API_KEY', 'from_file'], // File only - [['API_KEY' => 'from_env'], 'API_KEY=from_file', false, 'API_KEY', 'from_file'], // File wins over env - [['API_KEY' => ''], 'API_KEY=from_file', false, 'API_KEY', 'from_file'], // Empty env ignored - [[], 'API_KEY=', false, 'API_KEY', null], // Empty file ignored - [[], '', false, 'API_KEY', null], // File missing - - // Multiple key scenarios (iterates in order, returns first found) - [['KEY1' => 'from_env'], 'KEY2=file_val', false, ['KEY1', 'KEY2'], 'from_env'], // First key in env - [[], "KEY1=file_val\nKEY2=other", false, ['KEY1', 'KEY2'], 'file_val'], // First key in file - [[], '', false, ['KEY1', 'KEY2'], null], // No keys found - ]); - - it('handles required vs optional parameters', function ($keys, $required, $expectsException, $expectedMessage) { - // ARRANGE - $service = mockEnvService(false, ''); - $service->loadEnvFile(); - - // ACT & ASSERT - if ($expectsException) { - expect(fn () => $service->get($keys, $required)) - ->toThrow(\RuntimeException::class, $expectedMessage); - } else { - expect($service->get($keys, $required))->toBeNull(); - } - })->with([ - ['MISSING_KEY', true, true, 'Missing required environment variable: MISSING_KEY'], - [['KEY1', 'KEY2'], true, true, 'Missing required environment variables: KEY1, KEY2'], - ['MISSING_KEY', false, false, null], - ]); -}); diff --git a/tests/Unit/Services/FilesystemServiceTest.php b/tests/Unit/Services/FilesystemServiceTest.php deleted file mode 100644 index eb7b58e3..00000000 --- a/tests/Unit/Services/FilesystemServiceTest.php +++ /dev/null @@ -1,129 +0,0 @@ -expectedMethod === 'exists') { - $this->verified = $this->expectedArgs === [$files]; - return $this->returnValue; - } - return false; - } - - public function readFile(string $filename): string - { - if ($this->expectedMethod === 'readFile') { - $this->verified = $this->expectedArgs === [$filename]; - return $this->returnValue; - } - return ''; - } - - public function dumpFile(string $filename, $content): void - { - if ($this->expectedMethod === 'dumpFile') { - $this->verified = $this->expectedArgs === [$filename, $content]; - } - } - }; - - $service = new FilesystemService($fs); - - // ACT - $result = match ($method) { - 'exists' => $service->exists(...$args), - 'readFile' => $service->readFile(...$args), - 'dumpFile' => $service->dumpFile(...$args), - }; - - // ASSERT - expect($delegationVerified)->toBeTrue('Method delegation verified'); - if ($method !== 'dumpFile') { - expect($result)->toBe($expected); - } - })->with([ - 'exists method' => ['exists', ['/test/path'], true], - 'readFile method' => ['readFile', ['/test/file.txt'], 'file contents'], - 'dumpFile method' => ['dumpFile', ['/test/file.txt', 'content'], null], - ]); - - // - // Gap-Filling Methods (Business Logic Tests) - // ------------------------------------------------------------------------------- - - it('gets current working directory', function () { - // ARRANGE - $filesystem = new Filesystem(); - $service = new FilesystemService($filesystem); - - // ACT - $result = $service->getCwd(); - - // ASSERT - Should return a valid directory path - expect($result)->toBeString() - ->and($service->isDirectory($result))->toBeTrue('getCwd should return valid directory'); - }); - - it('checks if path is directory', function (string $path, bool $expected) { - // ARRANGE - $filesystem = new Filesystem(); - $service = new FilesystemService($filesystem); - - // ACT - $result = $service->isDirectory($path); - - // ASSERT - expect($result)->toBe($expected); - })->with([ - 'existing directory' => [__DIR__, true], - 'existing file' => [__FILE__, false], - 'nonexistent path' => ['/nonexistent/path/that/does/not/exist', false], - ]); - - it('gets parent directory', function (string $path, int $levels, string $expected) { - // ARRANGE - $filesystem = new Filesystem(); - $service = new FilesystemService($filesystem); - - // ACT - $result = $service->getParentDirectory($path, $levels); - - // ASSERT - expect($result)->toBe($expected); - })->with([ - 'single level' => ['/path/to/file.txt', 1, '/path/to'], - 'two levels' => ['/path/to/file.txt', 2, '/path'], - 'three levels' => ['/path/to/deep/file.txt', 3, '/path'], - ]); - - it('throws exception for invalid parent directory levels', function () { - // ARRANGE - $filesystem = new Filesystem(); - $service = new FilesystemService($filesystem); - - // ACT & ASSERT - expect(fn () => $service->getParentDirectory('/path', 0)) - ->toThrow(\InvalidArgumentException::class, 'Levels must be at least 1'); - }); -}); diff --git a/tests/Unit/Services/GitServiceTest.php b/tests/Unit/Services/GitServiceTest.php deleted file mode 100644 index 6be545d3..00000000 --- a/tests/Unit/Services/GitServiceTest.php +++ /dev/null @@ -1,119 +0,0 @@ -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/Services/IOServiceTest.php b/tests/Unit/Services/IOServiceTest.php deleted file mode 100644 index 9a1e50ab..00000000 --- a/tests/Unit/Services/IOServiceTest.php +++ /dev/null @@ -1,239 +0,0 @@ -command = $container->build(\Bigpixelrocket\DeployerPHP\Tests\Fixtures\TestConsoleCommand::class); - $this->tester = new CommandTester($this->command); - }); - - // - // Input Gathering - getOptionOrPrompt - // ------------------------------------------------------------------------------- - - it('handles option vs prompt scenarios correctly', function (array $options, string $method, mixed $expected) { - // ARRANGE - $this->command->setTestMethod($method); - - // ACT - $this->tester->execute($options); - $output = $this->tester->getDisplay(); - - // ASSERT - if (is_bool($expected)) { - expect($output)->toContain('Result: '.($expected ? 'true' : 'false')); - } else { - expect($output)->toContain("Result: {$expected}"); - } - })->with([ - 'string option provided' => [['--name' => 'production'], 'getOptionOrPrompt', 'production'], - 'empty string is valid value' => [['--name' => ''], 'getOptionOrPromptEmpty', ''], - 'no option executes closure' => [[], 'getOptionOrPromptEmpty', 'from-closure'], - 'boolean flag provided' => [['--yes' => true], 'getOptionOrPromptBoolean', true], - 'boolean flag not provided' => [[], 'getOptionOrPromptBoolean', false], - ]); - - it('supports different return types from prompt closure', function (mixed $value, string $display) { - // ARRANGE - $this->command->setTestMethod('getOptionOrPromptTypes', [$value]); - - // ACT - $this->tester->execute([]); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain("Result: {$display}"); - })->with([ - 'string' => ['text-value', 'text-value'], - 'integer' => [42, '42'], - 'boolean true' => [true, 'true'], - 'boolean false' => [false, 'false'], - 'array' => [['a', 'b'], '["a","b"]'], - ]); - - // - // Input Gathering - getValidatedOptionOrPrompt - // ------------------------------------------------------------------------------- - - it('validates CLI options and returns appropriate result', function (array $options, ?string $expected, bool $hasError) { - // ARRANGE - $this->command->setTestMethod('getValidatedOptionOrPromptValid'); - - // ACT - $this->tester->execute($options); - $output = $this->tester->getDisplay(); - - // ASSERT - if ($expected === null) { - expect($output)->toContain('Result: null'); - } else { - expect($output)->toContain("Result: {$expected}"); - } - - if ($hasError) { - expect($output)->toContain('✗'); - } - })->with([ - 'valid CLI option' => [['--name' => 'valid-name'], 'valid-name', false], - 'invalid CLI option (empty)' => [['--name' => ''], null, true], - ]); - - it('returns null when validator always fails', function () { - // ARRANGE - $this->command->setTestMethod('getValidatedOptionOrPromptInvalid'); - - // ACT - $this->tester->execute(['--name' => 'anything']); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain('✗') - ->and($output)->toContain('Always invalid') - ->and($output)->toContain('Result: null'); - }); - - // - // Output Methods - Status Messages - // ------------------------------------------------------------------------------- - - it('displays status messages with correct symbols and colors', function (string $method, string $message, string $symbol) { - // ARRANGE - $this->command->setTestMethod($method, [$message]); - - // ACT - $this->tester->execute([]); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain($symbol) - ->and($output)->toContain($message); - })->with([ - 'success' => ['success', 'Server added successfully', '✓'], - 'error' => ['error', 'Connection failed', '✗'], - 'warning' => ['warning', 'Skipping connection check', '⚠'], - 'info' => ['info', 'Configuration loaded', 'ℹ'], - ]); - - // - // Output Methods - Formatting - // ------------------------------------------------------------------------------- - - it('writes single and multiple lines correctly', function (string|array $lines, array $expectedContains) { - // ARRANGE - $this->command->setTestMethod('writeln', [$lines]); - - // ACT - $this->tester->execute([]); - $output = $this->tester->getDisplay(); - - // ASSERT - foreach ($expectedContains as $text) { - expect($output)->toContain($text); - } - })->with([ - 'single line' => ['Output line', ['Output line']], - 'multiple lines' => [['First line', 'Second line'], ['First line', 'Second line']], - ]); - - it('displays visual separators correctly', function (string $method, string $expectedContent) { - // ARRANGE - $this->command->setTestMethod($method, $method === 'h1' ? ['Server Configuration'] : []); - - // ACT - $this->tester->execute([]); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain($expectedContent); - })->with([ - 'h1 heading' => ['h1', '▸'], - 'h1 text' => ['h1', 'Server Configuration'], - 'hr separator' => ['hr', '╭───────'], - ]); - - // - // Output Methods - Command Hints - // ------------------------------------------------------------------------------- - - it('displays command hint with formatted options', function () { - // ARRANGE - $this->command->setTestMethod('showCommandHint', [ - 'server:add', - ['name' => 'prod-server', 'host' => '192.168.1.100', 'yes' => true], - ]); - - // ACT - $this->tester->execute([]); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain('Run non-interactively:') - ->and($output)->toContain('server:add') - ->and($output)->toContain('--name') - ->and($output)->toContain('--host') - ->and($output)->toContain('--yes'); - }); - - it('formats command options correctly and skips null/empty values', function (array $options, array $shouldContain, array $shouldNotContain) { - // ARRANGE - $this->command->setTestMethod('showCommandHint', ['server:add', $options]); - - // ACT - $this->tester->execute([]); - $output = $this->tester->getDisplay(); - - // ASSERT - foreach ($shouldContain as $text) { - expect($output)->toContain($text); - } - - foreach ($shouldNotContain as $text) { - expect($output)->not->toContain($text); - } - })->with([ - 'string options' => [ - ['name' => 'server1', 'host' => '192.168.1.1'], - ['--name', '--host', 'server1', '192.168.1.1'], - [], - ], - 'skip null and empty' => [ - ['name' => 'server1', 'host' => null, 'port' => ''], - ['--name'], - ['--host', '--port'], - ], - 'boolean true shown' => [ - ['yes' => true], - ['--yes'], - [], - ], - 'boolean false skipped' => [ - ['yes' => false], - [], - ['--yes'], - ], - ]); - - // - // Prompt Methods - Spin - // ------------------------------------------------------------------------------- - - it('executes callback and returns result from promptSpin', function () { - // ARRANGE - $this->command->setTestMethod('testPromptSpin'); - - // ACT - $this->tester->execute([]); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain('Spin result: success'); - }); -}); diff --git a/tests/Unit/Services/InventoryServiceTest.php b/tests/Unit/Services/InventoryServiceTest.php deleted file mode 100644 index 6f69b484..00000000 --- a/tests/Unit/Services/InventoryServiceTest.php +++ /dev/null @@ -1,242 +0,0 @@ -service = mockInventoryService(true, ''); - }); - - // - // Set operations - // ------------------------------------------------------------------------------- - - it('handles all set operation scenarios', function (string $path, mixed $value, ?array $existingData, bool $fileExists) { - // ARRANGE - $this->service = mockInventoryService($fileExists, $existingData ?? []); - $this->service->loadInventoryFile(); - - // ACT - $this->service->set($path, $value); - - // ASSERT - Verify data was actually stored correctly - $result = $this->service->get($path); - expect($result)->toBe($value); - })->with([ - // New file scenarios - 'simple nested path' => ['widgets.alpha', 'value', null, false], - 'deep nested path' => ['toys.robot.color', 'blue', null, false], - 'array value' => ['widgets.alpha', ['color' => 'red', 'size' => 10], null, false], - 'complex nested structure' => ['categories.shapes.widgets.circle.radius', ['value' => '5'], null, false], - 'single segment path' => ['widgets', ['alpha' => ['color' => 'red']], null, false], - - // Existing file scenarios - 'overwrite existing value' => ['widgets.alpha.color', 'blue', ['widgets' => ['alpha' => ['color' => 'red']]], true], - 'create intermediate paths' => ['widgets.beta.nested.color', 'green', ['widgets' => ['alpha' => ['color' => 'red']]], true], - 'type conflict resolution' => ['widgets.alpha', 'new-string-value', ['widgets' => ['alpha' => ['color' => 'red']]], true], - ]); - - // - // Get operations - // ------------------------------------------------------------------------------- - - it('handles all get operation scenarios', function (string $path, mixed $expected, ?array $inventoryData, bool $fileExists) { - // ARRANGE - $this->service = mockInventoryService($fileExists, $inventoryData ?? []); - $this->service->loadInventoryFile(); - - // ACT - $result = $this->service->get($path); - - // ASSERT - expect($result)->toBe($expected); - })->with([ - // File exists - positive cases - 'deep nested value' => [ - 'widgets.alpha.color', - 'red', - ['widgets' => ['alpha' => ['color' => 'red', 'size' => 'large'], 'beta' => ['color' => 'blue', 'size' => 'small']], 'animals' => ['cat' => ['sound' => 'meow', 'legs' => 4]]], - true - ], - 'nested object' => [ - 'widgets.alpha', - ['color' => 'red', 'size' => 'large'], - ['widgets' => ['alpha' => ['color' => 'red', 'size' => 'large'], 'beta' => ['color' => 'blue', 'size' => 'small']], 'animals' => ['cat' => ['sound' => 'meow', 'legs' => 4]]], - true - ], - 'top level collection' => [ - 'widgets', - ['alpha' => ['color' => 'red', 'size' => 'large'], 'beta' => ['color' => 'blue', 'size' => 'small']], - ['widgets' => ['alpha' => ['color' => 'red', 'size' => 'large'], 'beta' => ['color' => 'blue', 'size' => 'small']], 'animals' => ['cat' => ['sound' => 'meow', 'legs' => 4]]], - true - ], - 'different collection value' => [ - 'animals.cat.legs', - 4, - ['widgets' => ['alpha' => ['color' => 'red', 'size' => 'large'], 'beta' => ['color' => 'blue', 'size' => 'small']], 'animals' => ['cat' => ['sound' => 'meow', 'legs' => 4]]], - true - ], - 'single segment' => [ - 'animals', - ['cat' => ['sound' => 'meow', 'legs' => 4]], - ['widgets' => ['alpha' => ['color' => 'red', 'size' => 'large'], 'beta' => ['color' => 'blue', 'size' => 'small']], 'animals' => ['cat' => ['sound' => 'meow', 'legs' => 4]]], - true - ], - - // File exists - negative cases (non-existent paths) - 'non-existent deep path' => ['widgets.gamma.color', null, ['widgets' => ['alpha' => ['color' => 'red']]], true], - 'non-existent collection' => ['animals.dog', null, ['widgets' => ['alpha' => ['color' => 'red']]], true], - 'non-existent root' => ['missing', null, ['widgets' => ['alpha' => ['color' => 'red']]], true], - 'partial path match' => ['widgets.alpha.weight', null, ['widgets' => ['alpha' => ['color' => 'red']]], true], - - // File doesn't exist - 'file not found' => ['widgets', null, null, false], - ]); - - // - // Get with default value - // ------------------------------------------------------------------------------- - - it('returns default value when path does not exist', function (string $path, mixed $default, mixed $expected) { - // ARRANGE - $inventoryData = ['widgets' => ['alpha' => ['color' => 'red']]]; - $this->service = mockInventoryService(true, $inventoryData); - $this->service->loadInventoryFile(); - - // ACT - $result = $this->service->get($path, $default); - - // ASSERT - expect($result)->toBe($expected); - })->with([ - 'non-existent path with default' => ['widgets.beta', 'default-value', 'default-value'], - 'non-existent path with array default' => ['widgets.beta', ['color' => 'blue'], ['color' => 'blue']], - 'non-existent path with null default' => ['widgets.beta', null, null], - 'non-existent path with numeric default' => ['widgets.alpha.size', 10, 10], - 'non-existent path with boolean default' => ['widgets.alpha.visible', true, true], - 'existing path ignores default' => ['widgets.alpha.color', 'ignored', 'red'], - ]); - - // - // Delete operations - // ------------------------------------------------------------------------------- - - it('handles delete operations', function (string $path, array $inventoryData, string $scenario) { - // ARRANGE - $this->service = mockInventoryService(true, $inventoryData); - $this->service->loadInventoryFile(); - - // ACT - $this->service->delete($path); - - // ASSERT - Verify data was actually removed - expect($this->service->get($path))->toBeNull(); - - // Also verify other data remains intact (for precision testing) - if ($path === 'widgets.alpha.size') { - expect($this->service->get('widgets.alpha.color'))->toBe('red'); - } elseif ($path === 'widgets.alpha') { - expect($this->service->get('widgets.beta'))->not->toBeNull(); - } - })->with([ - 'removes specific property' => [ - 'widgets.alpha.size', - ['widgets' => ['alpha' => ['color' => 'red', 'size' => 10], 'beta' => ['color' => 'blue']]], - 'property removal' - ], - 'removes entire nested structure' => [ - 'widgets.alpha', - ['widgets' => ['alpha' => ['color' => 'red'], 'beta' => ['color' => 'blue']]], - 'structure removal' - ], - 'handles non-existent path gracefully' => [ - 'widgets.gamma', - ['widgets' => ['alpha' => ['color' => 'red']]], - 'graceful handling' - ], - ]); - - // - // Error handling - // ------------------------------------------------------------------------------- - - it('throws RuntimeException when file write fails during initialization', function () { - // ARRANGE - $service = mockInventoryService(false, '', false, true); - - // ACT & ASSERT - expect(fn () => $service->loadInventoryFile()) - ->toThrow(RuntimeException::class, 'Error writing inventory file'); - }); - - it('throws RuntimeException when file write fails during set operation', function () { - // ARRANGE - $service = mockInventoryService(true, ['existing' => 'data'], false, true); - $service->loadInventoryFile(); - - // ACT & ASSERT - expect(fn () => $service->set('widgets.alpha', 'value')) - ->toThrow(RuntimeException::class, 'Error writing inventory file'); - }); - - it('throws RuntimeException when file read fails', function () { - // ARRANGE - $service = mockInventoryService(true, 'content', true); - - // ACT & ASSERT - expect(fn () => $service->loadInventoryFile()) - ->toThrow(RuntimeException::class, 'Error reading inventory file'); - }); - - it('throws RuntimeException when attempting write before initialization', function () { - // ARRANGE - $service = mockInventoryService(false, ''); - - // ACT & ASSERT - expect(fn () => $service->set('widgets.alpha', 'value')) - ->toThrow(RuntimeException::class, 'Inventory not loaded. Call loadInventoryFile() first.'); - }); - - // - // Inventory file status - // ------------------------------------------------------------------------------- - - it('reports correct inventory file status for different scenarios', function (bool $fileExists, array|string $data, bool $fileError, bool $fileWriteError, bool $expectsException, ?string $expectedStatusPattern) { - // ARRANGE - $service = mockInventoryService($fileExists, $data, $fileError, $fileWriteError); - - // ACT & ASSERT - if ($expectsException) { - expect(fn () => $service->loadInventoryFile()) - ->toThrow(RuntimeException::class); - } else { - $service->loadInventoryFile(); - $status = $service->getInventoryFileStatus(); - expect($status)->toMatch($expectedStatusPattern); - } - })->with([ - // File exists with content - [true, ['widgets' => ['alpha' => ['color' => 'red', 'size' => 10]]], false, false, false, '/^Reading inventory from .+\.yml$/'], - - // File exists with single item - [true, ['single_key' => 'value'], false, false, false, '/^Reading inventory from .+\.yml$/'], - - // File exists but is empty - [true, [], false, false, false, '/^Reading inventory from .+\.yml$/'], - - // File exists with complex structure - [true, ['categories' => ['shapes' => ['circle' => ['radius' => 5]]]], false, false, false, '/^Reading inventory from .+\.yml$/'], - - // File exists but has read error (throws exception) - [true, ['key' => 'value'], true, false, true, null], - - // File doesn't exist and file write fails (throws exception) - [false, '', false, true, true, null], - ]); -}); diff --git a/tests/Unit/Services/ProcessServiceTest.php b/tests/Unit/Services/ProcessServiceTest.php deleted file mode 100644 index 95f30829..00000000 --- a/tests/Unit/Services/ProcessServiceTest.php +++ /dev/null @@ -1,58 +0,0 @@ -validCwd = __DIR__; - $this->proc = mockProcessService(); - }); - - it('executes process and returns result', function (?float $inputTimeout, ?float $expectedTimeout) { - // ARRANGE - $command = ['echo', 'test']; - - // ACT - $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->isSuccessful())->toBeTrue(); - })->with([ - 'null timeout defaults to 3.0' => [null, 3.0], - 'explicit default timeout' => [3.0, 3.0], - 'short custom timeout' => [1.5, 1.5], - 'long custom timeout' => [10.0, 10.0], - 'zero timeout removes timeout' => [0.0, null], - ]); - - it('throws exception when command is empty', function () { - // ARRANGE - $emptyCommand = []; - - // ACT & ASSERT - expect(fn () => $this->proc->run($emptyCommand, $this->validCwd)) - ->toThrow(InvalidArgumentException::class, 'Process command cannot be empty'); - }); - - it('throws exception for invalid working directories', function (string $invalidPath) { - // ARRANGE - $command = ['echo', 'test']; - - // ACT & ASSERT - expect(fn () => $this->proc->run($command, $invalidPath)) - ->toThrow(InvalidArgumentException::class); - })->with([ - 'empty string' => [''], - 'non-existent path' => ['/this/path/does/not/exist'], - 'nonexistent directory' => ['/nonexistent/directory/path'], - 'file instead of directory' => [__FILE__], - ]); -}); diff --git a/tests/Unit/Services/SSHServiceTest.php b/tests/Unit/Services/SSHServiceTest.php deleted file mode 100644 index 5c481237..00000000 --- a/tests/Unit/Services/SSHServiceTest.php +++ /dev/null @@ -1,173 +0,0 @@ -assertCanConnect('example.com', 22, 'deployer'); - throw new \Exception('Expected exception was not thrown'); - } catch (\RuntimeException $e) { - expect($e->getMessage()) - ->toContain('No SSH private key found') - ->and($e->getMessage())->toContain('~/.ssh/id_ed25519') - ->and($e->getMessage())->toContain('~/.ssh/id_rsa'); - } - }); - - it('resolves user-provided key path with tilde expansion', function () { - // ARRANGE - $filesystemService = mockFilesystemService(true, '', false, false, false, '/home/testuser/.ssh/custom_key'); - $envService = mockEnvService(false); - $service = new SSHService($envService, $filesystemService); - - // ACT & ASSERT - Test through public API - $reflection = new \ReflectionClass($service); - $method = $reflection->getMethod('resolvePrivateKeyPath'); - $actualPath = $method->invoke($service, '~/.ssh/custom_key'); - - expect($actualPath)->toBe('/home/testuser/.ssh/custom_key'); - }); - - it('prioritizes provided path over defaults and returns first existing', function () { - // ARRANGE - $mockFs = mockFilesystem(); - $mockFs->dumpFile('/home/testuser/custom/id_rsa', 'valid_key'); - $mockFs->dumpFile('/home/testuser/.ssh/id_ed25519', 'ed_key'); - $filesystemService = new FilesystemService($mockFs); - $envService = mockEnvService(false); - $service = new SSHService($envService, $filesystemService); - - // ACT - $reflection = new \ReflectionClass($service); - $method = $reflection->getMethod('resolvePrivateKeyPath'); - $actualPath = $method->invoke($service, '/home/testuser/custom/id_rsa'); - - // ASSERT - expect($actualPath)->toBe('/home/testuser/custom/id_rsa'); - }); - - it('falls back to default locations when no provided path', function () { - // ARRANGE - $mockFs = mockFilesystem(); - $mockFs->dumpFile('/home/testuser/.ssh/id_rsa', 'valid_key'); - $filesystemService = new FilesystemService($mockFs); - $envService = mockEnvService(false); - $service = new SSHService($envService, $filesystemService); - - // ACT - $reflection = new \ReflectionClass($service); - $method = $reflection->getMethod('resolvePrivateKeyPath'); - $actualPath = $method->invoke($service, null); - - // ASSERT - expect($actualPath)->toBe('/home/testuser/.ssh/id_rsa'); - }); - - it('expands tilde in paths correctly', function () { - // ARRANGE - $mockFs = mockFilesystem(); - $filesystemService = new FilesystemService($mockFs); - $envService = mockEnvService(false); - $service = new SSHService($envService, $filesystemService); - - // ACT - $reflection = new \ReflectionClass($service); - $method = $reflection->getMethod('expandHomePath'); - $expanded = $method->invoke($service, '~/.ssh/key'); - - // ASSERT - expect($expanded)->toBe('/home/testuser/.ssh/key'); - }); - - // - // Key Loading - // - - it('throws when key content cannot be parsed as private key', function () { - // ARRANGE - $invalidContent = 'invalid_key_content_not_ssh'; - $mockFs = mockFilesystem(true, $invalidContent, false, false, false, '/home/testuser/.ssh/id_rsa'); - $filesystemService = new FilesystemService($mockFs); - $envService = mockEnvService(false); - $service = new SSHService($envService, $filesystemService); - - // ACT & ASSERT - expect(fn () => $service->assertCanConnect('example.com', 22, 'deployer', '/home/testuser/.ssh/id_rsa')) - ->toThrow(\RuntimeException::class, 'Error parsing SSH private key'); - }); - - // - // File Validation - // - - it('validates script file exists before execution', function () { - // ARRANGE - $mockFs = mockFilesystem(false, '', false, false, false, './missing.sh'); - $filesystemService = new FilesystemService($mockFs); - $envService = mockEnvService(false); - $service = new SSHService($envService, $filesystemService); - - // ACT & ASSERT - expect(fn () => $service->executeScript('host', 22, 'user', './missing.sh')) - ->toThrow(\RuntimeException::class, 'Script file does not exist'); - }); - - it('validates local file exists before upload', function () { - // ARRANGE - $mockFs = mockFilesystem(false, '', false, false, false, './missing.txt'); - $filesystemService = new FilesystemService($mockFs); - $envService = mockEnvService(false); - $service = new SSHService($envService, $filesystemService); - - // ACT & ASSERT - expect(fn () => $service->uploadFile('host', 22, 'user', './missing.txt', '/remote/file.txt')) - ->toThrow(\RuntimeException::class, 'Local file does not exist'); - }); - - it('includes file path in error messages', function (string $method, array $args, string $expectedPath) { - // ARRANGE - $mockFs = mockFilesystem(false, '', false, false, false, $expectedPath); - $filesystemService = new FilesystemService($mockFs); - $envService = mockEnvService(false); - $service = new SSHService($envService, $filesystemService); - - // ACT & ASSERT - try { - $service->$method(...$args); - throw new \Exception('Expected exception was not thrown'); - } catch (\RuntimeException $e) { - expect($e->getMessage())->toContain($expectedPath); - } - })->with([ - 'script execution' => ['executeScript', ['host', 22, 'user', './deploy.sh'], './deploy.sh'], - 'file upload' => ['uploadFile', ['host', 22, 'user', './data.txt', '/remote'], './data.txt'], - ]); -}); diff --git a/tests/Unit/Services/VersionServiceTest.php b/tests/Unit/Services/VersionServiceTest.php deleted file mode 100644 index c171024e..00000000 --- a/tests/Unit/Services/VersionServiceTest.php +++ /dev/null @@ -1,84 +0,0 @@ -getVersion(); - - // ASSERT - Version should match valid version patterns (semver, git-describe, branch names, or commit hashes) - expect($version)->toMatch('/^(v?\d+\.\d+\.\d+|dev-|main-|master-|[0-9a-f]{7,40})/') - ->and($version)->not->toBeEmpty(); - - // ASSERT - If we're in a git repo, git version takes priority over fallback - if ($service->isGitRepository(getcwd())) { - expect($version)->not->toBe($fallback); // Git version used instead - } else { - expect($version)->toBe($fallback); // Fallback used - } - })->with([ - 'non-existent package with version fallback' => ['definitely/non/existent', 'v2.0.0-fallback'], - 'missing package with custom fallback' => ['missing/package', 'dev-custom'], - 'missing package with default fallback' => ['another/missing', 'dev-main'], - ]); - - it('detects git repository correctly', function (bool $hasGitDir, bool $expectedResult) { - // ARRANGE - $tempDir = sys_get_temp_dir() . '/test-' . uniqid(); - mkdir($tempDir); - - if ($hasGitDir) { - mkdir($tempDir . '/.git'); - } - - $service = mockVersionService(); - - // ACT - $result = $service->isGitRepository($tempDir); - - // ASSERT - expect($result)->toBe($expectedResult); - - // CLEANUP - if ($hasGitDir && is_dir($tempDir . '/.git')) { - rmdir($tempDir . '/.git'); - } - rmdir($tempDir); - })->with([ - 'git repository' => [true, true], - 'non-git directory' => [false, false], - ]); - - it('handles git command failures gracefully for all git methods', function (string $method) { - // ARRANGE - $invalidPath = '/absolutely/non/existent/path'; - $service = mockVersionService(); - - // ACT - $result = $service->$method($invalidPath); - - // ASSERT - expect($result)->toBeNull(); - })->with([ - 'exact git tag method' => ['getExactGitTag'], - 'git describe method' => ['getGitDescribeVersion'], - 'branch with commit method' => ['getBranchWithCommit'], - ]); - - it('returns null for non-existent composer packages', function () { - // ARRANGE - $service = mockVersionService('absolutely/non/existent/package'); - - // ACT - $result = $service->getVersionFromComposer(); - - // ASSERT - expect($result)->toBeNull(); - }); -}); diff --git a/tests/Unit/TestHelpersTest.php b/tests/Unit/TestHelpersTest.php deleted file mode 100644 index 81248e12..00000000 --- a/tests/Unit/TestHelpersTest.php +++ /dev/null @@ -1,242 +0,0 @@ -exists($checkPath))->toBe($expected, $description); - })->with([ - 'directory exists' => [true, 'content', 'config/app.yml', 'config', true, 'directory should exist'], - 'directory with trailing slash' => [true, 'content', 'config/app.yml', 'config/', true, 'directory with trailing slash should exist'], - 'existing file' => [true, 'content', 'inventory.yml', 'inventory.yml', true, 'existing file should exist'], - 'non-existent file in existing dir' => [true, 'content', 'config/app.yml', 'config/missing.yml', false, 'non-existent file should not exist'], - 'directory exists when file does not' => [false, '', 'config/app.yml', 'config', true, 'directory should exist'], - 'non-existent file (no substring match)' => [false, '', 'inventory.yml', 'inventory.yml', false, 'non-existent file should not exist even if directory matches'], - 'different path no substring match' => [false, '', 'config/app.yml', '/path/to/config/other.yml', false, 'non-existent file with directory substring should not exist'], - 'direct path match' => [true, 'test', 'inventory.yml', 'inventory.yml', true, 'direct match should work'], - 'path ending match' => [true, 'test', 'inventory.yml', '/path/to/inventory.yml', true, 'path ending match should work'], - 'different path ending' => [true, 'test', 'inventory.yml', '/different/inventory.yml', true, 'different path ending should work'], - ]); - - it('handles iterable file checks correctly', function () { - // ARRANGE - $mockFs = mockFilesystem(exists: true, content: 'test', initialPath: '.env'); - $mockFs->dumpFile('.env.example', 'example'); - - // ACT & ASSERT - expect($mockFs->exists(['.env', '.env.example']))->toBeTrue('all files exist') - ->and($mockFs->exists(['.env', '.missing']))->toBeFalse('one file missing'); - }); - - // - // File Read Operations - // ------------------------------------------------------------------------------- - - it('reads file content correctly', function () { - // ARRANGE - $mockFs = mockFilesystem(exists: true, content: 'test content', initialPath: 'test.txt'); - - // ACT - $result = $mockFs->readFile('test.txt'); - - // ASSERT - expect($result)->toBe('test content'); - }); - - it('throws IOException when reading non-existent file', function () { - // ARRANGE - $mockFs = mockFilesystem(exists: false, content: '', initialPath: 'missing.txt'); - - // ACT & ASSERT - expect(fn () => $mockFs->readFile('missing.txt')) - ->toThrow(IOException::class, 'File does not exist: missing.txt'); - }); - - it('throws IOException when configured to throw on read', function () { - // ARRANGE - $mockFs = mockFilesystem(exists: true, content: 'test', throwOnRead: true, initialPath: 'test.txt'); - - // ACT & ASSERT - expect(fn () => $mockFs->readFile('test.txt')) - ->toThrow(IOException::class, 'Permission denied'); - }); - - // - // Directory Operations - // ------------------------------------------------------------------------------- - - it('creates directories successfully', function () { - // ARRANGE - $mockFs = mockFilesystem(exists: true, content: '', initialPath: 'test.txt'); - - // ACT - $mockFs->mkdir('/new/directory'); - - // ASSERT - expect($mockFs->exists('/new/directory'))->toBeTrue(); - }); - - it('throws IOException when configured to throw on mkdir', function () { - // ARRANGE - $mockFs = mockFilesystem(exists: true, content: '', throwOnMkdir: true, initialPath: 'test.txt'); - - // ACT & ASSERT - expect(fn () => $mockFs->mkdir('/new/directory')) - ->toThrow(IOException::class, 'Permission denied'); - }); - - // - // File Write Operations - // ------------------------------------------------------------------------------- - - it('writes file content successfully', function () { - // ARRANGE - $mockFs = mockFilesystem(exists: false, content: '', initialPath: 'test.txt'); - - // ACT - $mockFs->dumpFile('new.txt', 'new content'); - - // ASSERT - expect($mockFs->exists('new.txt'))->toBeTrue() - ->and($mockFs->readFile('new.txt'))->toBe('new content'); - }); - - it('throws IOException when configured to throw on dump', function () { - // ARRANGE - $mockFs = mockFilesystem(exists: true, content: '', throwOnDump: true, initialPath: 'test.txt'); - - // ACT & ASSERT - expect(fn () => $mockFs->dumpFile('test.txt', 'content')) - ->toThrow(IOException::class, 'Write failed'); - }); -}); - -describe('mockEnvService', function () { - it('creates EnvService with mock filesystem', function () { - // ARRANGE - $service = mockEnvService(fileExists: true, fileContent: 'TEST_KEY=test_value'); - $service->loadEnvFile(); - - // ACT - $result = $service->get('TEST_KEY'); - - // ASSERT - expect($result)->toBe('test_value'); - }); -}); - -describe('mockInventoryService', function () { - it('creates InventoryService with various data formats', function ($data) { - // ARRANGE - $service = mockInventoryService(fileExists: true, data: $data); - $service->loadInventoryFile(); - - // ACT - $result = $service->get('widgets.alpha.color'); - - // ASSERT - expect($result)->toBe('red'); - })->with([ - 'array data' => [['widgets' => ['alpha' => ['color' => 'red']]]], - 'string data' => ['widgets:' . PHP_EOL . ' alpha:' . PHP_EOL . ' color: red'], - ]); -}); - -describe('mockFilesystemService', function () { - it('creates FilesystemService with mock filesystem', function () { - // ARRANGE - $service = mockFilesystemService(fileExists: true, fileContent: 'test content', filePath: 'test.txt'); - - // ACT - $content = $service->readFile('test.txt'); - - // ASSERT - expect($content)->toBe('test content'); - }); -}); - -describe('mockCommandContainer', function () { - it('creates container with all BaseCommand dependencies bound', function () { - // ARRANGE - $container = mockCommandContainer(); - $command = $container->build(\Bigpixelrocket\DeployerPHP\Tests\Fixtures\TestConsoleCommand::class); - - // ACT - Verify command is properly configured and executable - $tester = new \Symfony\Component\Console\Tester\CommandTester($command); - $exitCode = $tester->execute([]); - - // ASSERT - expect($command)->toBeInstanceOf(\Bigpixelrocket\DeployerPHP\Tests\Fixtures\TestConsoleCommand::class) - ->and($exitCode)->toBe(\Symfony\Component\Console\Command\Command::SUCCESS); - }); - - it('allows overriding specific services', function () { - // ARRANGE - $customSSH = mockSSHServiceWithBehavior(canConnect: false); - - // ACT - $container = mockCommandContainer(ssh: $customSSH); - $builtSSH = $container->build(\Bigpixelrocket\DeployerPHP\Services\SSHService::class); - - // ASSERT - expect($builtSSH)->toBe($customSSH); - }); - - it('allows overriding inventory data', function () { - // ARRANGE - $inventoryData = ['servers' => [ - ['name' => 'web1', 'host' => '192.168.1.1', 'port' => 22, 'username' => 'root'], - ]]; - - // ACT - $container = mockCommandContainer(inventoryData: $inventoryData); - $repository = $container->build(\Bigpixelrocket\DeployerPHP\Repositories\ServerRepository::class); - $servers = $repository->all(); - - // ASSERT - expect($servers)->toHaveCount(1) - ->and($servers[0]->name)->toBe('web1') - ->and($servers[0]->host)->toBe('192.168.1.1'); - }); -}); - -describe('setEnv', function () { - it('manages environment variables', function ($initialValue, $newValue, $expectSet) { - // ARRANGE - if ($initialValue !== null) { - setEnv('TEST_VAR', $initialValue); - } - - // ACT - setEnv('TEST_VAR', $newValue); - - // ASSERT - if ($expectSet) { - expect($_ENV['TEST_VAR'])->toBe($newValue) - ->and($_SERVER['TEST_VAR'])->toBe($newValue) - ->and(getenv('TEST_VAR'))->toBe($newValue); - } else { - expect(isset($_ENV['TEST_VAR']))->toBeFalse() - ->and(isset($_SERVER['TEST_VAR']))->toBeFalse() - ->and(getenv('TEST_VAR'))->toBeFalse(); - } - - // CLEANUP - setEnv('TEST_VAR', null); - })->with([ - 'sets variable' => [null, 'test_value', true], - 'unsets when null' => ['initial_value', null, false], - ]); -}); diff --git a/tests/Unit/Traits/ServerHelpersTraitTest.php b/tests/Unit/Traits/ServerHelpersTraitTest.php deleted file mode 100644 index f45bc724..00000000 --- a/tests/Unit/Traits/ServerHelpersTraitTest.php +++ /dev/null @@ -1,85 +0,0 @@ -command = $container->build(\Bigpixelrocket\DeployerPHP\Tests\Fixtures\TestConsoleCommand::class); - $this->tester = new CommandTester($this->command); - }); - - // - // displayServerDeets - // ------------------------------------------------------------------------------- - - it('displays server information with all fields', function () { - // ARRANGE - $this->command->setTestMethod('displayServerDeets', [ - new ServerDTO( - name: 'production-web', - host: '192.168.1.100', - port: 2222, - username: 'deployer', - privateKeyPath: '~/.ssh/custom_key' - ), - ]); - - // ACT - $this->tester->execute([]); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain('Name:') - ->and($output)->toContain('production-web') - ->and($output)->toContain('Host:') - ->and($output)->toContain('192.168.1.100') - ->and($output)->toContain('Port:') - ->and($output)->toContain('2222') - ->and($output)->toContain('User:') - ->and($output)->toContain('deployer') - ->and($output)->toContain('Key:') - ->and($output)->toContain('~/.ssh/custom_key'); - }); - - it('displays default SSH key message when privateKeyPath is null', function () { - // ARRANGE - $this->command->setTestMethod('displayServerDeets', [ - new ServerDTO(name: 'test-server', host: '127.0.0.1'), - ]); - - // ACT - $this->tester->execute([]); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain('Key:') - ->and($output)->toContain('default') - ->and($output)->toContain('~/.ssh/id_ed25519') - ->and($output)->toContain('~/.ssh/id_rsa'); - }); - - it('displays server info with default values', function () { - // ARRANGE - $this->command->setTestMethod('displayServerDeets', [ - new ServerDTO(name: 'minimal', host: 'example.com'), - ]); - - // ACT - $this->tester->execute([]); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain('minimal') - ->and($output)->toContain('example.com') - ->and($output)->toContain('22') - ->and($output)->toContain('root'); - }); -}); diff --git a/tests/Unit/Traits/ServerValidationTraitTest.php b/tests/Unit/Traits/ServerValidationTraitTest.php deleted file mode 100644 index bfec1cfc..00000000 --- a/tests/Unit/Traits/ServerValidationTraitTest.php +++ /dev/null @@ -1,245 +0,0 @@ -validateNameInput($name); - } - - /** - * Expose protected validateHostInput for testing. - */ - public function testValidateHost(mixed $host): ?string - { - return $this->validateHostInput($host); - } - - /** - * Expose protected validatePortInput for testing. - */ - public function testValidatePort(mixed $portString): ?string - { - return $this->validatePortInput($portString); - } -} - -// -// Unit tests -// ------------------------------------------------------------------------------- - -require_once __DIR__ . '/../../TestHelpers.php'; - -describe('ServerValidationTrait', function () { - beforeEach(function () { - $this->validator = new TestServerValidator(); - }); - - // - // validateNameInput - // ------------------------------------------------------------------------------- - - it('accepts valid server names', function (string $name) { - // ARRANGE - $this->validator->servers = mockServerRepository(true, ['servers' => []]); - - // ACT - $error = $this->validator->testValidateName($name); - - // ASSERT - expect($error)->toBeNull(); - })->with([ - 'simple name' => ['web1'], - 'hyphenated' => ['web-server-01'], - 'underscored' => ['web_server_01'], - 'numeric' => ['server123'], - 'mixed case' => ['WebServer01'], - ]); - - it('rejects empty server names', function () { - // ARRANGE - $this->validator->servers = mockServerRepository(true, ['servers' => []]); - - // ACT - $error = $this->validator->testValidateName(''); - - // ASSERT - expect($error)->toContain('cannot be empty'); - }); - - it('rejects duplicate server names', function () { - // ARRANGE - $this->validator->servers = mockServerRepository(true, [ - 'servers' => [ - ['name' => 'existing-server', 'host' => '192.168.1.1', 'port' => 22, 'username' => 'root'], - ], - ]); - - // ACT - $error = $this->validator->testValidateName('existing-server'); - - // ASSERT - expect($error)->toContain('already exists'); - }); - - // - // validateHostInput - // ------------------------------------------------------------------------------- - - it('accepts valid IPv4 addresses', function (string $host) { - // ARRANGE - $this->validator->servers = mockServerRepository(true, ['servers' => []]); - - // ACT - $error = $this->validator->testValidateHost($host); - - // ASSERT - expect($error)->toBeNull(); - })->with([ - 'standard IPv4' => ['192.168.1.100'], - 'localhost' => ['127.0.0.1'], - 'zero address' => ['0.0.0.0'], - 'broadcast' => ['255.255.255.255'], - ]); - - it('accepts valid IPv6 addresses', function (string $host) { - // ARRANGE - $this->validator->servers = mockServerRepository(true, ['servers' => []]); - - // ACT - $error = $this->validator->testValidateHost($host); - - // ASSERT - expect($error)->toBeNull(); - })->with([ - 'full IPv6' => ['2001:0db8:85a3:0000:0000:8a2e:0370:7334'], - 'compressed IPv6' => ['2001:db8::1'], - 'localhost' => ['::1'], - ]); - - it('accepts valid domain names', function (string $host) { - // ARRANGE - $this->validator->servers = mockServerRepository(true, ['servers' => []]); - - // ACT - $error = $this->validator->testValidateHost($host); - - // ASSERT - expect($error)->toBeNull(); - })->with([ - 'simple domain' => ['example.com'], - 'subdomain' => ['server.example.com'], - 'deep subdomain' => ['app.server.example.com'], - 'hyphenated domain' => ['my-server.example.com'], - 'numeric in domain' => ['server1.example.com'], - ]); - - it('rejects invalid hosts with error messages', function (string $host, string $expectedError) { - // ARRANGE - $this->validator->servers = mockServerRepository(true, ['servers' => []]); - - // ACT - $error = $this->validator->testValidateHost($host); - - // ASSERT - expect($error)->toContain($expectedError); - })->with([ - 'empty string' => ['', 'valid'], - 'underscore' => ['server_name', 'valid'], - 'spaces' => ['my server', 'valid'], - 'special chars' => ['server!@#', 'valid'], - 'double dots' => ['example..com', 'valid'], - ]); - - it('rejects duplicate server hosts', function (string $host) { - // ARRANGE - $this->validator->servers = mockServerRepository(true, [ - 'servers' => [ - ['name' => 'existing-server', 'host' => $host, 'port' => 22, 'username' => 'root'], - ], - ]); - - // ACT - $error = $this->validator->testValidateHost($host); - - // ASSERT - expect($error)->toContain('already used by server') - ->and($error)->toContain('existing-server'); - })->with([ - 'IP address' => ['192.168.1.100'], - 'domain' => ['example.com'], - ]); - - // - // validatePortInput - // ------------------------------------------------------------------------------- - - it('accepts valid port numbers', function (string $portString) { - // ARRANGE & ACT - $error = $this->validator->testValidatePort($portString); - - // ASSERT - expect($error)->toBeNull(); - })->with([ - 'SSH default' => ['22'], - 'HTTP' => ['80'], - 'HTTPS' => ['443'], - 'custom high' => ['8080'], - 'alternative SSH' => ['2222'], - 'minimum port' => ['1'], - 'maximum port' => ['65535'], - ]); - - it('rejects non-numeric port strings', function (string $portString) { - // ARRANGE & ACT - $error = $this->validator->testValidatePort($portString); - - // ASSERT - expect($error)->toContain('must be a number'); - })->with([ - 'letters' => ['abc'], - 'empty' => [''], - 'special chars' => ['22!'], - 'floating point' => ['22.5'], - ]); - - it('rejects out of range port numbers', function (string $portString, string $expectedError) { - // ARRANGE & ACT - $error = $this->validator->testValidatePort($portString); - - // ASSERT - expect($error)->toContain($expectedError); - })->with([ - 'zero' => ['0', 'between 1 and 65535'], - 'too high' => ['65536', 'between 1 and 65535'], - 'way too high' => ['100000', 'between 1 and 65535'], - ]); - - it('rejects negative port numbers as non-numeric', function (string $portString) { - // ARRANGE & ACT - $error = $this->validator->testValidatePort($portString); - - // ASSERT - expect($error)->toContain('must be a number'); - })->with([ - 'negative' => ['-1'], - 'large negative' => ['-100'], - ]); -}); diff --git a/tests/Unit/Traits/SiteHelpersTraitTest.php b/tests/Unit/Traits/SiteHelpersTraitTest.php deleted file mode 100644 index 4959d082..00000000 --- a/tests/Unit/Traits/SiteHelpersTraitTest.php +++ /dev/null @@ -1,120 +0,0 @@ -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 deleted file mode 100644 index 227727c0..00000000 --- a/tests/Unit/Traits/SiteValidationTraitTest.php +++ /dev/null @@ -1,313 +0,0 @@ -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"); - }); -});