diff --git a/.cursor/rules/00-main.mdc b/.cursor/rules/00-main.mdc index 398bec98..c1d986f9 100644 --- a/.cursor/rules/00-main.mdc +++ b/.cursor/rules/00-main.mdc @@ -32,6 +32,6 @@ The goal is for all the code in this repository to appear as if it were written This can include everything from naming files, classes, variables, or array keys to the precedence and type of parameters passed to a function, to how logic flows and how the code is organized or commented. -**✔️ Don't worry about tests:** Write or run tests ONLY if specifically instructed +**✔️ Tests are off-limits:** Don't run or edit tests; run or edit tests ONLY if explicitly instructed to do so! **🧠 AI Agent Protocol:** ULTRATHINK → STEP BY STEP → ACT diff --git a/.cursor/rules/01-architecture.mdc b/.cursor/rules/01-architecture.mdc index 288bb225..836a6436 100644 --- a/.cursor/rules/01-architecture.mdc +++ b/.cursor/rules/01-architecture.mdc @@ -1,6 +1,5 @@ --- -globs: app/**/*.php,tests/**/*.php -alwaysApply: false +alwaysApply: true --- ## Architecture Rules @@ -83,10 +82,18 @@ $service = $container->build(TestService::class); - Services provide atomic, reusable functionality with no console I/O - Services accept plain PHP data types and return plain PHP data types -- Services must be stateless and dependency-injected +- Services must be dependency-injected via constructor - Services handle core business logic, external API calls, file operations - Complex orchestration shared by multiple Commands should be extracted to dedicated Services +**Service State:** + +- **Stateless Services:** Pure operations with no internal state (e.g., validators, calculators, API clients) +- **Stateful Services:** Services that manage configuration or cached data (e.g., config loaders, file managers, repositories) +- Stateful services should use lazy loading when initialization is expensive or path-dependent +- State must be initialized explicitly via public methods before use (e.g., `load()`, `initialize()`) +- Services should document their stateful nature and initialization requirements + ### Console I/O Rules - Only Commands perform console input/output operations diff --git a/.cursor/rules/02-tests.mdc b/.cursor/rules/02-tests.mdc index c64dcc2c..32a406af 100644 --- a/.cursor/rules/02-tests.mdc +++ b/.cursor/rules/02-tests.mdc @@ -1,5 +1,5 @@ --- -globs: app/**/*.php,tests/**/*.php +alwaysApply: true --- ## Testing Rules diff --git a/.cursor/rules/03-commands.mdc b/.cursor/rules/03-commands.mdc index f42e64b1..4fffe725 100644 --- a/.cursor/rules/03-commands.mdc +++ b/.cursor/rules/03-commands.mdc @@ -1,6 +1,5 @@ --- -globs: app/Console/*.php,app/Contracts/BaseCommand.php,app/SymfonyApp.php -description: Symfony Console rules +alwaysApply: true --- ## Symfony Console Rules diff --git a/.gitignore b/.gitignore index f28d386d..d30beda4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,10 @@ .vscode/ node_modules/ vendor/ -*.cache -*.log .DS_Store .env .env.* +*.cache +*.log +inventory.yml Thumbs.db diff --git a/README.md b/README.md index ce13199c..a145c1c5 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ ``` + ╭────────────────────────────────────────── ┌┬┐┌─┐┌─┐┬ ┌─┐┬ ┬┌─┐┬─┐ ││├┤ ├─┘│ │ │└┬┘├┤ ├┬┘ ─┴┘└─┘┴ ┴─┘└─┘ ┴ └─┘┴└─PHP - The Server Provisioning & Deployment Tool for PHP - - Support this project on GitHub ♥ https://github.com/bigpixelrocket/deployer-php + The Server & Site Deployment Tool for PHP + ╰────────────────────────────────────────── ``` [![Tests](https://img.shields.io/badge/tests-passing-brightgreen.svg)](https://github.com/bigpixelrocket/deployer-php) diff --git a/app/Console/HelloCommand.php b/app/Console/HelloCommand.php index 0641c9ec..16ec8b25 100644 --- a/app/Console/HelloCommand.php +++ b/app/Console/HelloCommand.php @@ -18,6 +18,8 @@ class HelloCommand extends BaseCommand */ protected function execute(InputInterface $input, OutputInterface $output): int { + parent::execute($input, $output); + $user = $this->env->get(['USER', 'USERNAME'], false) ?? 'there'; $this->io->success('Hello ' . $user . '!'); diff --git a/app/Contracts/BaseCommand.php b/app/Contracts/BaseCommand.php index 9a042069..fc949f50 100644 --- a/app/Contracts/BaseCommand.php +++ b/app/Contracts/BaseCommand.php @@ -11,6 +11,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Console\Input\InputOption; abstract class BaseCommand extends Command { @@ -26,35 +27,79 @@ public function __construct( parent::__construct(); } + // + // Common config + // ------------------------------------------------------------------------------- + + /** + * Add custom env and inventory options. + */ + protected function configure(): void + { + parent::configure(); + + $this->addOption( + 'env', + null, + InputOption::VALUE_OPTIONAL, + 'Custom path to .env file (defaults to .env in the current working directory)' + ); + + $this->addOption( + 'inventory', + null, + InputOption::VALUE_OPTIONAL, + 'Custom path to inventory.yml file (defaults to inventory.yml in the current working directory)' + ); + } + /** - * Initialize IO early so subclasses can use $this->io in initialize()/interact(). + * Initialize IO and services early. */ protected function initialize(InputInterface $input, OutputInterface $output): void { parent::initialize($input, $output); + $this->io = new SymfonyStyle($input, $output); $this->isQuiet = $output->isQuiet(); - $envStatus = $this->env->getEnvFileStatus(); - $inventoryStatus = $this->inventory->getInventoryFileStatus(); + // + // Initialize env service + + /** @var ?string $customEnvPath */ + $customEnvPath = $input->getOption('env'); + $this->env->setCustomPath($customEnvPath); + $this->env->loadEnvFile(); + + // + // Initialize inventory service + + /** @var ?string $customInventoryPath */ + $customInventoryPath = $input->getOption('inventory'); + $this->inventory->setCustomPath($customInventoryPath); + $this->inventory->loadInventoryFile(); + } - $this->hr(); + /** + * Display env and inventory statuses. + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $envStatus = $this->env->getEnvFileStatus(); + $color = str_starts_with($envStatus, 'No .env') ? 'yellow' : 'gray'; $this->writeln([ ' Environment: ', - ' '.$envStatus.'', + " {$envStatus}", '', + ]); + + $inventoryStatus = $this->inventory->getInventoryFileStatus(); + $this->writeln([ ' Inventory: ', ' '.$inventoryStatus.'', '', ]); - } - - /** - * The main execution method in Symfony commands. - */ - protected function execute(InputInterface $input, OutputInterface $output): int - { return Command::SUCCESS; } diff --git a/app/Services/EnvService.php b/app/Services/EnvService.php index a65bc8a0..286d4f00 100644 --- a/app/Services/EnvService.php +++ b/app/Services/EnvService.php @@ -15,13 +15,14 @@ class EnvService /** @var array */ private array $dotenv = []; + private ?string $envPath = null; + private string $envFileStatus = ''; public function __construct( private readonly Filesystem $filesystem, private readonly Dotenv $dotenvParser, ) { - $this->loadDotenvFile(); } // @@ -53,12 +54,42 @@ public function get(array|string $keys, bool $required = true): ?string if ($required) { $list = implode(', ', $keysList); $label = count($keysList) > 1 ? 'variables' : 'variable'; - throw new \RuntimeException("Missing environment {$label}: {$list}"); + throw new \RuntimeException("Missing required environment {$label}: {$list}"); } return null; } + /** + * Set a custom .env path. + */ + public function setCustomPath(?string $path): void + { + $this->envPath = $path; + } + + /** + * Load and parse .env file if it exists. + */ + public function loadEnvFile(): void + { + $this->dotenv = []; + + $path = $this->getEnvPath(); + + if (!$this->filesystem->exists($path)) { + $this->envFileStatus = "No .env file found at {$path}"; + return; + } + + $this->readDotenv(); + + $this->envFileStatus = "Reading variables from {$path}"; + if (!count($this->dotenv)) { + $this->envFileStatus = "No variables found in {$path}"; + } + } + /** * Get the status of the .env file. */ @@ -72,34 +103,33 @@ public function getEnvFileStatus(): string // ------------------------------------------------------------------------------- /** - * Load and parse .env file if it exists. + * Get the resolved .env path (custom or default). */ - private function loadDotenvFile(): void + private function getEnvPath(): string { - $envPath = rtrim((string) getcwd(), '/') . '/.env'; + return $this->envPath ?? rtrim((string) getcwd(), '/') . '/.env'; + } - if (!$this->filesystem->exists($envPath)) { - $envDir = dirname($envPath); - $this->envFileStatus = "No .env file found at {$envDir}"; - return; - } + /** + * Read .env file into internal array. + * + * @throws \RuntimeException If file cannot be read or parsed + */ + private function readDotenv(): void + { + $path = $this->getEnvPath(); try { - $content = $this->filesystem->readFile($envPath); - $parsed = $this->dotenvParser->parse($content, $envPath); + $content = $this->filesystem->readFile($path); + $parsed = $this->dotenvParser->parse($content, $path); foreach ($parsed as $k => $v) { if (is_string($k) && is_string($v)) { $this->dotenv[$k] = $v; } } - - $varCount = count($this->dotenv); - $label = $varCount === 1 ? 'variable' : 'variables'; - $this->envFileStatus = "Reading {$varCount} {$label} from {$envPath}"; - } catch (\Throwable) { - $this->dotenv = []; - $this->envFileStatus = "Error reading .env file from {$envPath}"; + } catch (\Throwable $e) { + throw new \RuntimeException("Error reading .env file from {$path}: " . $e->getMessage()); } } } diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php index ac3c1aed..a42de67f 100644 --- a/app/Services/InventoryService.php +++ b/app/Services/InventoryService.php @@ -34,17 +34,16 @@ */ class InventoryService { - private readonly string $inventoryPath; - private readonly string $inventoryDir; + /** @var array */ + private array $inventory = []; - private string $inventoryFileStatus = ''; + private ?string $inventoryPath = null; + + private ?string $inventoryFileStatus = null; public function __construct( private readonly Filesystem $filesystem, ) { - $this->inventoryPath = rtrim((string) getcwd(), '/').'/.deployer/inventory.yml'; - $this->inventoryDir = dirname($this->inventoryPath); - $this->initializeInventoryFile(); } // @@ -56,61 +55,67 @@ public function __construct( */ public function set(string $path, mixed $value): void { - $inventory = $this->readInventory(); $segments = $this->parsePath($path); - $this->setByPath($inventory, $segments, $value); - $this->writeInventory($inventory); + $this->setByPath($this->inventory, $segments, $value); + $this->writeInventory(); } /** * Get a value using dot notation path. + * + * @param mixed $default Default value to return if path doesn't exist */ - public function get(string $path): mixed + public function get(string $path, mixed $default = null): mixed { - $inventory = $this->readInventory(); $segments = $this->parsePath($path); + $value = $this->getByPath($this->inventory, $segments); - return $this->getByPath($inventory, $segments); + return $value ?? $default; } /** - * Get the entire inventory structure. - * - * @return array + * Delete a value using dot notation path. */ - public function getAll(): array + public function delete(string $path): void { - return $this->readInventory(); + $segments = $this->parsePath($path); + + $this->unsetByPath($this->inventory, $segments); + $this->writeInventory(); } /** - * Check if a path exists using dot notation. + * Set a custom inventory path. */ - public function has(string $path): bool + public function setCustomPath(?string $path): void { - $inventory = $this->readInventory(); - $segments = $this->parsePath($path); - - return $this->hasByPath($inventory, $segments); + $this->inventoryPath = $path; } /** - * Delete a value using dot notation path. + * Load and parse inventory file if it exists. */ - public function delete(string $path): void + public function loadInventoryFile(): void { - $inventory = $this->readInventory(); - $segments = $this->parsePath($path); + $this->inventory = []; + + $path = $this->getInventoryPath(); + + // Initialize empty inventory file if it doesn't exist + if (!$this->filesystem->exists($path)) { + $this->inventoryFileStatus = "Creating inventory file at {$path}"; + $this->writeInventory(); + } - $this->unsetByPath($inventory, $segments); - $this->writeInventory($inventory); + $this->readInventory(); + $this->inventoryFileStatus = "Reading inventory from {$path}"; } /** * Get the status of the inventory file. */ - public function getInventoryFileStatus(): string + public function getInventoryFileStatus(): ?string { return $this->inventoryFileStatus; } @@ -119,40 +124,6 @@ public function getInventoryFileStatus(): string // Private // ------------------------------------------------------------------------------- - // - // Initialization - - /** - * Initialize inventory file and set status. - */ - private function initializeInventoryFile(): void - { - if (!$this->filesystem->exists($this->inventoryPath)) { - // Create empty inventory file - try { - if (!$this->filesystem->exists($this->inventoryDir)) { - $this->filesystem->mkdir($this->inventoryDir, 0775); - } - - $emptyYaml = Yaml::dump([], 2, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); - $this->filesystem->dumpFile($this->inventoryPath, $emptyYaml); - $this->inventoryFileStatus = "Creating inventory file at {$this->inventoryPath}"; - } catch (\Throwable $e) { - $this->inventoryFileStatus = "Error creating inventory file at {$this->inventoryPath}: {$e->getMessage()}"; - } - - return; - } - - // File exists - validate it - try { - $this->readInventory(); - $this->inventoryFileStatus = "Reading inventory from {$this->inventoryPath}"; - } catch (\Throwable $e) { - $this->inventoryFileStatus = "Error reading inventory file from {$this->inventoryPath}: {$e->getMessage()}"; - } - } - // // Dot Notation Helpers @@ -211,26 +182,6 @@ private function setByPath(array &$data, array $segments, mixed $value): void $current = $value; } - /** - * Check if path exists in nested array using dot notation path segments. - * - * @param array $data - * @param array $segments - */ - private function hasByPath(array $data, array $segments): bool - { - $current = $data; - - foreach ($segments as $segment) { - if (!is_array($current) || !array_key_exists($segment, $current)) { - return false; - } - $current = $current[$segment]; - } - - return true; - } - /** * Remove path from nested array using dot notation path segments. * @@ -266,48 +217,50 @@ private function unsetByPath(array &$data, array $segments): bool // File Operations /** - * Read inventory YAML into a structured array. - * - * @return array + * Get the resolved inventory path (custom or default). */ - private function readInventory(): array + private function getInventoryPath(): string { - $path = $this->inventoryPath; + return $this->inventoryPath ?? rtrim((string) getcwd(), '/') . '/inventory.yml'; + } - if (!$this->filesystem->exists($path)) { - return []; - } + /** + * Read inventory YAML into internal array. + * + * @throws \RuntimeException If file cannot be read or parsed + */ + private function readInventory(): void + { + $path = $this->getInventoryPath(); - $raw = $this->filesystem->readFile($path); - $parsed = Yaml::parse($raw); + try { + $raw = $this->filesystem->readFile($path); + $parsed = Yaml::parse($raw); - /** @var array $result */ - $result = is_array($parsed) ? $parsed : []; - return $result; + /** @var array $inventory */ + $inventory = is_array($parsed) ? $parsed : []; + $this->inventory = $inventory; + } catch (\Throwable $e) { + throw new \RuntimeException("Error reading inventory file from {$path}: " . $e->getMessage()); + } } /** - * Persist inventory data to YAML file. - * - * @param array $inventory + * Persist internal inventory to YAML file. */ - private function writeInventory(array $inventory): void + private function writeInventory(): void { - $path = $this->inventoryPath; - - if (!$this->filesystem->exists($this->inventoryDir)) { - try { - $this->filesystem->mkdir($this->inventoryDir, 0775); - } catch (\Throwable $e) { - throw new \RuntimeException("Unable to create inventory directory: {$this->inventoryDir}", 0, $e); - } + if (null === $this->inventoryFileStatus) { + throw new \RuntimeException('Inventory not loaded. Call loadInventoryFile() first.'); } - $yaml = Yaml::dump($inventory, 2, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); + $path = $this->getInventoryPath(); + try { + $yaml = Yaml::dump($this->inventory, 2, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); $this->filesystem->dumpFile($path, $yaml); } catch (\Throwable $e) { - throw new \RuntimeException("Failed to write inventory file at {$path}", 0, $e); + throw new \RuntimeException("Error writing inventory file at {$path}: " . $e->getMessage()); } } } diff --git a/app/SymfonyApp.php b/app/SymfonyApp.php index 0f1f858b..ca28e53b 100644 --- a/app/SymfonyApp.php +++ b/app/SymfonyApp.php @@ -51,7 +51,9 @@ public function doRun(InputInterface $input, OutputInterface $output): int { $this->io = new SymfonyStyle($input, $output); - $this->displayBanner(); + if (!$output->isQuiet()) { + $this->displayBanner(); + } return parent::doRun($input, $output); } @@ -70,14 +72,14 @@ private function displayBanner(): void // Simple, compact banner $banner = [ '', + '╭──────────────────────────────────────────', ' ┌┬┐┌─┐┌─┐┬ ┌─┐┬ ┬┌─┐┬─┐', ' ││├┤ ├─┘│ │ │└┬┘├┤ ├┬┘', ' ─┴┘└─┘┴ ┴─┘└─┘ ┴ └─┘┴└─PHP '.$version.'', '', - ' The Server Provisioning & Deployment Tool for PHP', - '', - ' Support this project on GitHub https://github.com/bigpixelrocket/deployer-php', - '', + ' The Server & Site Deployment Tool for PHP', + '╰──────────────────────────────────────────', + '' ]; // Display the banner diff --git a/tests/Integration/SymfonyAppTest.php b/tests/Integration/SymfonyAppTest.php index f3fcff43..f8cbd904 100644 --- a/tests/Integration/SymfonyAppTest.php +++ b/tests/Integration/SymfonyAppTest.php @@ -37,7 +37,7 @@ // // Banner display - it('displays complete banner with branding elements', function (array $expectedBannerElements) { + it('displays banner with branding elements', function (array $expectedBannerElements) { // ARRANGE $container = new Container(); $app = $container->build(SymfonyApp::class); @@ -62,13 +62,11 @@ } } })->with([ - 'complete banner' => [[ + 'banner' => [[ '┌┬┐┌─┐┌─┐┬ ┌─┐┬ ┬┌─┐┬─┐', // ASCII art line 1 ' ││├┤ ├─┘│ │ │└┬┘├┤ ├┬┘', // ASCII art line 2 'VERSION_LINE', // Dynamic version line - 'The Server Provisioning & Deployment Tool for PHP', - 'Support this project on GitHub ♥', - 'https://github.com/bigpixelrocket/deployer-php', + 'The Server & Site Deployment Tool for PHP', ]] ]); diff --git a/tests/Unit/Contracts/BaseCommandTest.php b/tests/Unit/Contracts/BaseCommandTest.php index 42b3b43c..9f7d8a4f 100644 --- a/tests/Unit/Contracts/BaseCommandTest.php +++ b/tests/Unit/Contracts/BaseCommandTest.php @@ -32,6 +32,7 @@ public function __construct( protected function configure(): void { + parent::configure(); $this->setName($this->testName)->setDescription('Test command for BaseCommand testing'); } @@ -48,48 +49,162 @@ protected function execute(InputInterface $input, OutputInterface $output): int // ------------------------------------------------------------------------------- describe('BaseCommand', function () { - it('constructs with dependencies and executes with proper output', function (bool $hasEnvFile, bool $hasInventoryFile, string $commandName, string $envPattern, string $inventoryPattern) { + it('constructs with dependencies and registers custom options', function () { // ARRANGE $container = new Container(); - $env = mockEnvService($hasEnvFile); - $inventory = mockInventoryService($hasInventoryFile); + $env = mockEnvService(true); + $inventory = mockInventoryService(true); // ACT - $command = new TestableBaseCommand($container, $env, $inventory, $commandName); + $command = new TestableBaseCommand($container, $env, $inventory, 'test'); + + // ASSERT + expect($command->getName())->toBe('test') + ->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 status output', function (bool $hasEnvFile, string $expectedEnvMessage) { + // ARRANGE + $container = new Container(); + $env = mockEnvService($hasEnvFile); + $inventory = mockInventoryService(true); + $command = new TestableBaseCommand($container, $env, $inventory); $tester = new CommandTester($command); + + // ACT $exitCode = $tester->execute([]); $output = $tester->getDisplay(); // ASSERT - expect($command->getName())->toBe($commandName) - ->and($exitCode)->toBe(Command::SUCCESS) - ->and($output)->toMatch($envPattern) - ->and($output)->toMatch($inventoryPattern) + expect($exitCode)->toBe(Command::SUCCESS) ->and($output)->toContain('Environment:') ->and($output)->toContain('Inventory:') - ->and($output)->toContain('╭───────') + ->and($output)->toContain($expectedEnvMessage) + ->and($output)->toContain('Reading inventory from') ->and($output)->toContain('Test command executed successfully'); })->with([ - 'both files, simple name' => [true, true, 'test', '/Environment:[\s\S]*variable[\s\S]*from/', '/Inventory:[\s\S]*Reading inventory from/'], - 'no files, kebab case' => [false, false, 'deploy-server', '/Environment:[\s\S]*No \\.env file found/', '/Inventory:[\s\S]*Creating inventory file/'], - 'env only, colon separated' => [true, false, 'server:deploy', '/Environment:[\s\S]*variable[\s\S]*from/', '/Inventory:[\s\S]*Creating inventory file/'], - 'inventory only, default' => [false, true, 'test-command', '/Environment:[\s\S]*No \\.env file found/', '/Inventory:[\s\S]*Reading inventory from/'], + 'env file exists' => [true, 'Reading variables from'], + 'no env file' => [false, 'No .env file found'], ]); - it('suppresses status display and wrapper methods in quiet mode', function () { + it('displays correct env status messages for different scenarios', function (bool $hasEnvFile, string $envPattern) { // ARRANGE $container = new Container(); - $env = mockEnvService(true); + $env = mockEnvService($hasEnvFile); $inventory = mockInventoryService(true); + $command = new TestableBaseCommand($container, $env, $inventory); + $tester = new CommandTester($command); // ACT + $exitCode = $tester->execute([]); + $output = $tester->getDisplay(); + + // ASSERT + expect($exitCode)->toBe(Command::SUCCESS) + ->and($output)->toMatch($envPattern) + ->and($output)->toContain('Reading inventory from'); + })->with([ + 'env file exists' => [true, '/Reading variables from/'], + 'no env file' => [false, '/No \\.env file found/'], + ]); + + it('suppresses all output in quiet mode', function () { + // ARRANGE + $container = new Container(); + $env = mockEnvService(true); + $inventory = mockInventoryService(true); $command = new TestableBaseCommand($container, $env, $inventory); $tester = new CommandTester($command); - $exitCode = $tester->execute([], ['verbosity' => \Symfony\Component\Console\Output\OutputInterface::VERBOSITY_QUIET]); + + // ACT + $exitCode = $tester->execute([], ['verbosity' => OutputInterface::VERBOSITY_QUIET]); $output = $tester->getDisplay(); // ASSERT expect($exitCode)->toBe(Command::SUCCESS) ->and($output)->toBe(''); }); + + it('wrapper methods writeln, text, and hr respect quiet mode', function (string $method) { + // ARRANGE + $container = new Container(); + $env = mockEnvService(true); + $inventory = mockInventoryService(true); + + $command = new class ($container, $env, $inventory) extends BaseCommand { + protected function configure(): void + { + parent::configure(); + $this->setName('test-wrapper'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + parent::execute($input, $output); + + match ($input->getOption('test-method')) { + 'writeln' => $this->writeln('Test message'), + 'text' => $this->text('Test message'), + 'hr' => $this->hr(), + default => null, + }; + + return Command::SUCCESS; + } + }; + + $command->getDefinition()->addOption( + new \Symfony\Component\Console\Input\InputOption('test-method', null, \Symfony\Component\Console\Input\InputOption::VALUE_REQUIRED) + ); + + $tester = new CommandTester($command); + + // ACT - Normal mode + $tester->execute(['--test-method' => $method]); + $normalOutput = $tester->getDisplay(); + + // ACT - Quiet mode + $tester->execute(['--test-method' => $method], ['verbosity' => OutputInterface::VERBOSITY_QUIET]); + $quietOutput = $tester->getDisplay(); + + // ASSERT + expect($normalOutput)->not->toBe('') + ->and($quietOutput)->toBe(''); + })->with(['writeln', 'text', 'hr']); + + it('hr displays separator line', function () { + // ARRANGE + $container = new Container(); + $env = mockEnvService(true); + $inventory = mockInventoryService(true); + + $command = new class ($container, $env, $inventory) extends BaseCommand { + protected function configure(): void + { + parent::configure(); + $this->setName('test-hr'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->hr(); + return Command::SUCCESS; + } + }; + + $tester = new CommandTester($command); + + // ACT + $tester->execute([]); + $output = $tester->getDisplay(); + + // ASSERT + expect($output)->toContain('╭───────') + ->and(strlen($output))->toBeGreaterThan(40); + }); }); diff --git a/tests/Unit/Services/EnvServiceTest.php b/tests/Unit/Services/EnvServiceTest.php index b97eed11..f6f1f923 100644 --- a/tests/Unit/Services/EnvServiceTest.php +++ b/tests/Unit/Services/EnvServiceTest.php @@ -19,36 +19,34 @@ }); - it('reports correct status for different .env file scenarios', function ($fileExists, $fileContent, $fileError, $expectedStatusPattern) { + it('reports correct status for different .env file scenarios', function ($fileExists, $fileContent, $expectsException, $expectedStatusPattern) { // ARRANGE $service = new EnvService( - mockFilesystem($fileExists, $fileContent, $fileError, false, false, '.env'), + mockFilesystem($fileExists, $fileContent, $expectsException, false, false, '.env'), new Dotenv() ); - // ACT - $status = $service->getEnvFileStatus(); - - // ASSERT - expect($status)->toMatch($expectedStatusPattern); + // ACT & ASSERT + if ($expectsException) { + expect(fn () => $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 and loads successfully with variables - [true, "API_KEY=test\nDB_HOST=localhost", false, '/^Reading 2 variables from .+\.env$/'], - - // File exists with single variable - [true, 'SINGLE_KEY=value', false, '/^Reading 1 variable from .+\.env$/'], - // File exists but is empty (no variables) - [true, '', false, '/^Reading 0 variables from .+\.env$/'], + [true, '', false, '/^No variables found in .+\.env$/'], - // File exists but has read error - [true, 'API_KEY=test', true, '/^Error reading \.env file from .+\.env$/'], + // File exists and loads successfully with variables + [true, "API_KEY=test\nDB_HOST=localhost", false, '/^Reading variables from .+\.env$/'], - // File exists with malformed content (triggers parse error) - [true, "VALID=test\nINVALID_LINE\nOTHER=value", false, '/^Error reading \.env file 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) { @@ -60,6 +58,7 @@ mockFilesystem(!empty($fileContent), $fileContent, $fileError, false, false, '.env'), new Dotenv() ); + $service->loadEnvFile(); // ACT $result = $service->get($keys, false); @@ -73,17 +72,16 @@ } })->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 - [[], 'API_KEY=value', true, 'API_KEY', null], // File read error + [[], '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 + [['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) { @@ -92,6 +90,7 @@ mockFilesystem(false, '', false, false, false, '.env'), new Dotenv() ); + $service->loadEnvFile(); // ACT & ASSERT if ($expectsException) { @@ -101,9 +100,8 @@ expect($service->get($keys, $required))->toBeNull(); } })->with([ - ['MISSING_KEY', true, true, 'Missing environment variable: MISSING_KEY'], - [['KEY1', 'KEY2'], true, true, 'Missing environment variables: KEY1, KEY2'], + ['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], - ['MISSING_KEY', true, true, 'Missing environment variable: MISSING_KEY'], // Default required=true ]); }); diff --git a/tests/Unit/Services/InventoryServiceTest.php b/tests/Unit/Services/InventoryServiceTest.php index b290d07d..240a7cd9 100644 --- a/tests/Unit/Services/InventoryServiceTest.php +++ b/tests/Unit/Services/InventoryServiceTest.php @@ -13,7 +13,7 @@ describe('InventoryService', function () { beforeEach(function () { - $this->filesystem = mockFilesystem(); + $this->filesystem = mockFilesystem(true, '', false, false, false, 'inventory.yml'); $this->service = new InventoryService($this->filesystem); }); @@ -25,11 +25,13 @@ // ARRANGE if ($fileExists && $existingData) { $yamlContent = Yaml::dump($existingData, 2, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); - $this->filesystem = mockFilesystem(true, $yamlContent, false); + $this->filesystem = mockFilesystem(true, $yamlContent, false, false, false, 'inventory.yml'); } else { - $this->filesystem = mockFilesystem(false); + $this->filesystem = mockFilesystem(false, '', false, false, false, 'inventory.yml'); } + $this->service = new InventoryService($this->filesystem); + $this->service->loadInventoryFile(); // ACT $this->service->set($path, $value); @@ -59,11 +61,13 @@ // ARRANGE if ($fileExists && $inventoryData) { $yamlContent = Yaml::dump($inventoryData, 2, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); - $this->filesystem = mockFilesystem(true, $yamlContent, false); + $this->filesystem = mockFilesystem(true, $yamlContent, false, false, false, 'inventory.yml'); } else { - $this->filesystem = mockFilesystem(false); + $this->filesystem = mockFilesystem(false, '', false, false, false, 'inventory.yml'); } + $this->service = new InventoryService($this->filesystem); + $this->service->loadInventoryFile(); // ACT $result = $this->service->get($path); @@ -114,78 +118,29 @@ ]); // - // Get all operations - // ------------------------------------------------------------------------------- - - it('handles getAll scenarios', function (array $expected, bool $fileExists) { - // ARRANGE - if ($fileExists) { - $yamlContent = Yaml::dump($expected, 2, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); - $this->filesystem = mockFilesystem(true, $yamlContent, false); - } else { - $this->filesystem = mockFilesystem(false); - } - $this->service = new InventoryService($this->filesystem); - - // ACT - $result = $this->service->getAll(); - - // ASSERT - expect($result)->toBe($expected); - })->with([ - 'returns entire structure' => [ - ['servers' => ['web1' => ['host' => 'example.com']], 'databases' => ['db1' => ['host' => 'db.com']]], - true - ], - 'returns empty array when file missing' => [ - [], - false - ], - ]); - - // - // Has operations + // Get with default value // ------------------------------------------------------------------------------- - it('correctly identifies existing paths', function (string $path, bool $expected, bool $fileExists) { + it('returns default value when path does not exist', function (string $path, mixed $default, mixed $expected) { // ARRANGE - if ($fileExists) { - $inventoryData = [ - 'servers' => [ - 'web1' => ['host' => 'example.com', 'port' => 22], - 'web2' => ['host' => 'test.com'], - ], - 'databases' => ['db1' => ['host' => 'db.com']], - ]; - $yamlContent = Yaml::dump($inventoryData, 2, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); - $this->filesystem = mockFilesystem(true, $yamlContent, false); - } else { - $this->filesystem = mockFilesystem(false); - } + $inventoryData = ['servers' => ['web1' => ['host' => 'example.com']]]; + $yamlContent = Yaml::dump($inventoryData, 2, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); + $this->filesystem = mockFilesystem(true, $yamlContent, false, false, false, 'inventory.yml'); $this->service = new InventoryService($this->filesystem); + $this->service->loadInventoryFile(); // ACT - $result = $this->service->has($path); + $result = $this->service->get($path, $default); // ASSERT expect($result)->toBe($expected); })->with([ - // Existing paths - ['servers', true, true], - ['servers.web1', true, true], - ['servers.web1.host', true, true], - ['servers.web1.port', true, true], - ['databases.db1.host', true, true], - - // Non-existent paths - ['servers.web3', false, true], - ['servers.web1.user', false, true], - ['missing', false, true], - ['databases.db2', false, true], - ['servers.web1.host.subdomain', false, true], - - // File doesn't exist - ['servers.web1', false, false], + 'non-existent path with default' => ['servers.web2', 'default-server', 'default-server'], + 'non-existent path with array default' => ['servers.web2', ['host' => 'default.com'], ['host' => 'default.com']], + 'non-existent path with null default' => ['servers.web2', null, null], + 'non-existent path with numeric default' => ['servers.web1.port', 22, 22], + 'non-existent path with boolean default' => ['servers.web1.enabled', true, true], + 'existing path ignores default' => ['servers.web1.host', 'ignored', 'example.com'], ]); // @@ -195,20 +150,21 @@ it('handles delete operations', function (string $path, array $inventoryData, string $scenario) { // ARRANGE $yamlContent = Yaml::dump($inventoryData, 2, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); - $this->filesystem = mockFilesystem(true, $yamlContent, false); + $this->filesystem = mockFilesystem(true, $yamlContent, false, false, false, 'inventory.yml'); $this->service = new InventoryService($this->filesystem); + $this->service->loadInventoryFile(); // ACT $this->service->delete($path); // ASSERT - Verify data was actually removed - expect($this->service->has($path))->toBeFalse(); + expect($this->service->get($path))->toBeNull(); // Also verify other data remains intact (for precision testing) if ($path === 'servers.web1.port') { expect($this->service->get('servers.web1.host'))->toBe('example.com'); } elseif ($path === 'servers.web1') { - expect($this->service->has('servers.web2'))->toBeTrue(); + expect($this->service->get('servers.web2'))->not->toBeNull(); } })->with([ 'removes specific property' => [ @@ -229,73 +185,69 @@ ]); // - // Edge cases + // Error handling // ------------------------------------------------------------------------------- - it('handles invalid YAML parsing gracefully', function () { + it('throws RuntimeException when file write fails during initialization', function () { // ARRANGE - $this->filesystem = mockFilesystem(true, '', false); // Empty content returns null when parsed - $this->service = new InventoryService($this->filesystem); - - // ACT - $result = $this->service->get('servers'); + $filesystem = mockFilesystem(false, '', false, false, true, 'inventory.yml'); + $service = new InventoryService($filesystem); - // ASSERT - expect($result)->toBeNull(); + // ACT & ASSERT + expect(fn () => $service->loadInventoryFile()) + ->toThrow(RuntimeException::class, 'Error writing inventory file'); }); - // - // Error handling - // ------------------------------------------------------------------------------- - - it('throws RuntimeException when directory creation fails', function () { + it('throws RuntimeException when file write fails during set operation', function () { // ARRANGE - $filesystem = mockFilesystem(false, '', false, true, false); + $filesystem = mockFilesystem(true, Yaml::dump(['existing' => 'data'], 2, 4), false, false, true, 'inventory.yml'); $service = new InventoryService($filesystem); + $service->loadInventoryFile(); // ACT & ASSERT expect(fn () => $service->set('servers.web1', 'value')) - ->toThrow(RuntimeException::class, 'Unable to create inventory directory'); + ->toThrow(RuntimeException::class, 'Error writing inventory file'); }); - it('throws RuntimeException when file write fails', function () { + it('throws RuntimeException when file read fails', function () { // ARRANGE - $filesystem = mockFilesystem(true, Yaml::dump([], 2, 4), false, false, true); + $filesystem = mockFilesystem(true, 'content', true, false, false, 'inventory.yml'); $service = new InventoryService($filesystem); // ACT & ASSERT - expect(fn () => $service->set('servers.web1', 'value')) - ->toThrow(RuntimeException::class, 'Failed to write inventory file'); + expect(fn () => $service->loadInventoryFile()) + ->toThrow(RuntimeException::class, 'Error reading inventory file'); }); - it('throws ParseException when YAML is malformed', function () { + it('throws RuntimeException when attempting write before initialization', function () { // ARRANGE - $filesystem = mockFilesystem(true, "invalid: [\n - broken", false); // Malformed YAML + $filesystem = mockFilesystem(false, '', false, false, false, 'inventory.yml'); $service = new InventoryService($filesystem); // ACT & ASSERT - expect(fn () => $service->get('servers')) - ->toThrow(\Symfony\Component\Yaml\Exception\ParseException::class); + expect(fn () => $service->set('servers.web1', '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, string $fileContent, bool $fileError, bool $dirCreateError, bool $fileWriteError, string $expectedStatusPattern) { + it('reports correct inventory file status for different scenarios', function (bool $fileExists, string $fileContent, bool $fileError, bool $fileWriteError, bool $expectsException, ?string $expectedStatusPattern) { // ARRANGE - $filesystem = mockFilesystem($fileExists, $fileContent, $fileError, $dirCreateError, $fileWriteError); + $filesystem = mockFilesystem($fileExists, $fileContent, $fileError, false, $fileWriteError, 'inventory.yml'); $service = new InventoryService($filesystem); - // ACT - $status = $service->getInventoryFileStatus(); - - // ASSERT - expect($status)->toMatch($expectedStatusPattern); + // ACT & ASSERT + if ($expectsException) { + expect(fn () => $service->loadInventoryFile()) + ->toThrow(RuntimeException::class); + } else { + $service->loadInventoryFile(); + $status = $service->getInventoryFileStatus(); + expect($status)->toMatch($expectedStatusPattern); + } })->with([ - // File doesn't exist - should create empty file - [false, '', false, false, false, '/^Creating inventory file at .+\.yml$/'], - // File exists with content [true, Yaml::dump(['servers' => ['web1' => ['host' => 'example.com', 'port' => 22]]], 2, 4), false, false, false, '/^Reading inventory from .+\.yml$/'], @@ -305,16 +257,13 @@ // File exists but is empty [true, Yaml::dump([], 2, 4), false, false, false, '/^Reading inventory from .+\.yml$/'], - // File exists but has read error - [true, Yaml::dump(['key' => 'value'], 2, 4), true, false, false, '/^Error reading inventory file from .+\.yml: .+$/'], - - // File doesn't exist and directory creation fails - [false, '', false, true, false, '/^Error creating inventory file at .+\.yml: .+$/'], - - // File doesn't exist and file write fails - [false, '', false, false, true, '/^Error creating inventory file at .+\.yml: .+$/'], - // File exists with complex structure [true, Yaml::dump(['environments' => ['prod' => ['db' => ['host' => 'prod-db']]]], 2, 4), false, false, false, '/^Reading inventory from .+\.yml$/'], + + // File exists but has read error (throws exception) + [true, Yaml::dump(['key' => 'value'], 2, 4), true, false, true, null], + + // File doesn't exist and file write fails (throws exception) + [false, '', false, true, true, null], ]); });