diff --git a/.cursor/commands/_analyze.md b/.cursor/commands/_analyze.md new file mode 100644 index 00000000..fa4ce551 --- /dev/null +++ b/.cursor/commands/_analyze.md @@ -0,0 +1 @@ +meticulously catalog and analyze all the changes diff --git a/.cursor/commands/_in-branch.md b/.cursor/commands/_in-branch.md new file mode 100644 index 00000000..693478cd --- /dev/null +++ b/.cursor/commands/_in-branch.md @@ -0,0 +1 @@ +in this branch, compared to the branch it's based on diff --git a/.cursor/commands/_in-diff.md b/.cursor/commands/_in-diff.md new file mode 100644 index 00000000..50cc58c5 --- /dev/null +++ b/.cursor/commands/_in-diff.md @@ -0,0 +1 @@ +in this Git working tree, staged or unstaged diff --git a/.cursor/commands/_report.md b/.cursor/commands/_report.md new file mode 100644 index 00000000..9423d750 --- /dev/null +++ b/.cursor/commands/_report.md @@ -0,0 +1 @@ +provide a detailed report but don't make any changes yet. diff --git a/.cursor/commands/_review.md b/.cursor/commands/_review.md new file mode 100644 index 00000000..8f246a83 --- /dev/null +++ b/.cursor/commands/_review.md @@ -0,0 +1 @@ +review everything thoroughly with a focus on where these fall short of our development, architecture and testing rules diff --git a/.cursor/commands/analyze.md b/.cursor/commands/analyze.md deleted file mode 100644 index f74617a9..00000000 --- a/.cursor/commands/analyze.md +++ /dev/null @@ -1 +0,0 @@ -Analyze and meticulously catalog all the changes in this branch, compared to it's base branch, including all the changes that haven't been committed yet. diff --git a/.cursor/commands/create-branch.md b/.cursor/commands/create-branch.md index 122b1f3e..25956302 100644 --- a/.cursor/commands/create-branch.md +++ b/.cursor/commands/create-branch.md @@ -1,4 +1,5 @@ -Analyze the changes in this Git working tree and create a new branch with a suitable name. +Meticulously catalog and analyze all the changes in this Git working tree, staged or unstaged +and create a new branch with a suitable name. Create the branch only (no commits yet). Use Conventional Commit types as branch prefixes: feat/, fix/, docs/, style/, refactor/, perf/, test/, build/, ci/, chore/, revert/. diff --git a/.cursor/commands/create-commits.md b/.cursor/commands/create-commits.md index 22db4206..3f068980 100644 --- a/.cursor/commands/create-commits.md +++ b/.cursor/commands/create-commits.md @@ -1,4 +1,5 @@ -Analyze the changes in this Git working tree and create one or more commits with suitable titles. +Meticulously catalog and analyze all the changes in this Git working tree, staged or unstaged +and create one or more commits with suitable titles. Use Conventional Commits to group related changes into cohesive commits (commits should be independently meaningful). diff --git a/.cursor/commands/review.md b/.cursor/commands/review.md deleted file mode 100644 index 3865e181..00000000 --- a/.cursor/commands/review.md +++ /dev/null @@ -1 +0,0 @@ -Review everything thoroughly and report back on where the changes fall short of our development, architecture and testing rules. diff --git a/.cursor/rules/01-architecture.mdc b/.cursor/rules/01-architecture.mdc index 1de8ac26..b1d7cc1e 100644 --- a/.cursor/rules/01-architecture.mdc +++ b/.cursor/rules/01-architecture.mdc @@ -55,7 +55,7 @@ $service = new MyService(new Dependency()); **Rule:** ALL object creation must use `App::build()` except for value objects, DTOs, and pure data structures. -**Testing Exception:** Direct `Container` instantiation is acceptable in tests to isolate state and avoid shared singleton behavior. Production code must always use `App::build()`. +**Testing Exception:** Direct instantiation is acceptable in tests to make mocking and isolation simpler. Production code must always use `App::build()`. ```php // ✅ ACCEPTABLE IN TESTS - Isolated container for test isolation diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php new file mode 100644 index 00000000..7ffb6d30 --- /dev/null +++ b/app/Services/InventoryService.php @@ -0,0 +1,270 @@ +set('servers.production.host', 'example.com'); + * $inventory->set('servers.production.user', 'deployer'); + * + * // Or set entire object at once + * $inventory->set('servers.production', ['host' => 'example.com', 'user' => 'deployer']); + * + * // Retrieve values at any depth + * $inventory->get('servers.production.host'); // 'example.com' + * $inventory->get('servers.production'); // ['host' => 'example.com', 'user' => 'deployer'] + * $inventory->get('servers'); // ['production' => ['host' => 'example.com', 'user' => 'deployer']] + * $inventory->get('servers.staging'); // null + * + * // Check if path exists + * if ($inventory->has('servers.production')) { + * // Path exists + * } + * + * // Delete path + * $inventory->delete('servers.production'); + */ +class InventoryService +{ + private readonly string $inventoryPath; + private readonly string $inventoryDir; + + public function __construct( + private readonly Filesystem $filesystem, + ) { + $this->inventoryPath = rtrim((string) getcwd(), '/').'/.deployer/inventory.yml'; + $this->inventoryDir = dirname($this->inventoryPath); + } + + // + // Public + // ------------------------------------------------------------------------------- + + /** + * Set a value using dot notation path. + */ + public function set(string $path, mixed $value): void + { + $inventory = $this->readInventory(); + $segments = $this->parsePath($path); + + $this->setByPath($inventory, $segments, $value); + $this->writeInventory($inventory); + } + + /** + * Get a value using dot notation path. + */ + public function get(string $path): mixed + { + $inventory = $this->readInventory(); + $segments = $this->parsePath($path); + + return $this->getByPath($inventory, $segments); + } + + /** + * Get the entire inventory structure. + * + * @return array + */ + public function getAll(): array + { + return $this->readInventory(); + } + + /** + * Check if a path exists using dot notation. + */ + public function has(string $path): bool + { + $inventory = $this->readInventory(); + $segments = $this->parsePath($path); + + return $this->hasByPath($inventory, $segments); + } + + /** + * Delete a value using dot notation path. + */ + public function delete(string $path): void + { + $inventory = $this->readInventory(); + $segments = $this->parsePath($path); + + $this->unsetByPath($inventory, $segments); + $this->writeInventory($inventory); + } + + // + // Private + // ------------------------------------------------------------------------------- + + // + // Dot Notation Helpers + + /** + * Parse dot notation path into array segments. + * + * @return array + */ + private function parsePath(string $path): array + { + return explode('.', $path); + } + + /** + * Get value from nested array using dot notation path segments. + * + * @param array $data + * @param array $segments + */ + private function getByPath(array $data, array $segments): mixed + { + $current = $data; + + foreach ($segments as $segment) { + if (!is_array($current) || !array_key_exists($segment, $current)) { + return null; + } + $current = $current[$segment]; + } + + return $current; + } + + /** + * Set value in nested array using dot notation path segments. + * + * @param array $data + * @param array $segments + */ + private function setByPath(array &$data, array $segments, mixed $value): void + { + $current = &$data; + + foreach ($segments as $segment) { + if (!is_array($current)) { + $current = []; + } + + if (!array_key_exists($segment, $current)) { + $current[$segment] = []; + } + + $current = &$current[$segment]; + } + + $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. + * + * @param array $data + * @param array $segments + */ + private function unsetByPath(array &$data, array $segments): bool + { + if (empty($segments)) { + return false; + } + + $lastSegment = array_pop($segments); + $current = &$data; + + // Navigate to parent of target + foreach ($segments as $segment) { + if (!is_array($current) || !array_key_exists($segment, $current)) { + return false; // Path doesn't exist + } + $current = &$current[$segment]; + } + + if (!is_array($current) || !array_key_exists($lastSegment, $current)) { + return false; // Target doesn't exist + } + + unset($current[$lastSegment]); + return true; + } + + // + // File Operations + + /** + * Read inventory YAML into a structured array. + * + * @return array + */ + private function readInventory(): array + { + $path = $this->inventoryPath; + + if (!$this->filesystem->exists($path)) { + return []; + } + + $raw = $this->filesystem->readFile($path); + $parsed = Yaml::parse($raw); + + /** @var array $result */ + $result = is_array($parsed) ? $parsed : []; + return $result; + } + + /** + * Persist inventory data to YAML file. + * + * @param array $inventory + */ + private function writeInventory(array $inventory): 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); + } + } + + $yaml = Yaml::dump($inventory, 2, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); + try { + $this->filesystem->dumpFile($path, $yaml); + } catch (\Throwable $e) { + throw new \RuntimeException("Failed to write inventory file at {$path}", 0, $e); + } + } +} diff --git a/tests/TestHelpers.php b/tests/TestHelpers.php index c4a8aef4..80838350 100644 --- a/tests/TestHelpers.php +++ b/tests/TestHelpers.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use Symfony\Component\Filesystem\Filesystem; + if (!function_exists('setEnv')) { /** * Set or unset environment variables for testing. @@ -18,3 +20,54 @@ function setEnv(string $key, ?string $value): void } } } + +if (!function_exists('mockFilesystem')) { + /** + * Create a mock filesystem for testing with comprehensive error simulation. + */ + function mockFilesystem( + bool $exists = true, + string $content = '', + bool $throwOnRead = false, + bool $throwOnMkdir = false, + bool $throwOnDump = false + ): Filesystem { + return new class ($exists, $content, $throwOnRead, $throwOnMkdir, $throwOnDump) extends Filesystem { + public function __construct( + private readonly bool $exists, + private readonly string $content, + private readonly bool $throwOnRead, + private readonly bool $throwOnMkdir, + private readonly bool $throwOnDump + ) { + } + + public function exists(string|iterable $files): bool + { + return $this->exists; + } + + public function readFile(string $filename): string + { + if ($this->throwOnRead) { + throw new \RuntimeException('Permission denied'); + } + return $this->content; + } + + public function mkdir($dirs, int $mode = 0777): void + { + if ($this->throwOnMkdir) { + throw new \Exception('Permission denied'); + } + } + + public function dumpFile(string $filename, $content): void + { + if ($this->throwOnDump) { + throw new \Exception('Write failed'); + } + } + }; + } +} diff --git a/tests/Unit/EnvServiceTest.php b/tests/Unit/EnvServiceTest.php index a217c78d..4cbd7471 100644 --- a/tests/Unit/EnvServiceTest.php +++ b/tests/Unit/EnvServiceTest.php @@ -4,7 +4,6 @@ use Bigpixelrocket\DeployerPHP\Services\EnvService; use Symfony\Component\Dotenv\Dotenv; -use Symfony\Component\Filesystem\Filesystem; // // Test helpers @@ -12,25 +11,6 @@ require_once __DIR__ . '/../TestHelpers.php'; -function mockFilesystem(bool $exists = true, string $content = '', bool $throwError = false): Filesystem -{ - return new class ($exists, $content, $throwError) extends Filesystem { - public function __construct(private readonly bool $exists, private readonly string $content, private readonly bool $error) - { - } - public function exists(string|iterable $files): bool - { - return $this->exists; - } - public function readFile(string $filename): string - { - if ($this->error) { - throw new \RuntimeException('Permission denied'); - } - return $this->content; - } - }; -} // diff --git a/tests/Unit/InventoryServiceTest.php b/tests/Unit/InventoryServiceTest.php new file mode 100644 index 00000000..ea79c0db --- /dev/null +++ b/tests/Unit/InventoryServiceTest.php @@ -0,0 +1,310 @@ +filesystem = mockFilesystem(); + $this->service = new InventoryService($this->filesystem); + }); + + // + // Set operations + // ------------------------------------------------------------------------------- + + it('handles all set operation scenarios', function (string $path, mixed $value, ?array $existingData, bool $fileExists) { + // ARRANGE + if ($fileExists && $existingData) { + $yamlContent = Yaml::dump($existingData, 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 + $this->service->set($path, $value); + + // ASSERT - Verify filesystem interactions occurred + expect(true)->toBeTrue(); // Operation completed without exception + })->with([ + // New file scenarios + 'simple nested path' => ['servers.web1', 'value', null, false], + 'deep nested path' => ['app.db.host', 'localhost', null, false], + 'array value' => ['servers.web1', ['host' => 'example.com', 'port' => 22], null, false], + 'complex nested structure' => ['deployments.prod.servers.web.config', ['cpu' => '2'], null, false], + 'single segment path' => ['servers', ['web1' => ['host' => 'example.com']], null, false], + + // Existing file scenarios + 'overwrite existing value' => ['servers.web1.host', 'new.com', ['servers' => ['web1' => ['host' => 'old.com']]], true], + 'create intermediate paths' => ['servers.web2.database.host', 'db.example.com', ['servers' => ['web1' => ['host' => 'example.com']]], true], + 'type conflict resolution' => ['servers.web1', 'new-string-value', ['servers' => ['web1' => ['host' => 'example.com']]], true], + ]); + + // + // GET Operations + // ---- + + it('handles all get operation scenarios', function (string $path, mixed $expected, ?array $inventoryData, bool $fileExists) { + // ARRANGE + if ($fileExists && $inventoryData) { + $yamlContent = Yaml::dump($inventoryData, 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->get($path); + + // ASSERT + expect($result)->toBe($expected); + })->with([ + // File exists - positive cases + 'deep nested value' => [ + 'servers.production.host', + 'prod.example.com', + ['servers' => ['production' => ['host' => 'prod.example.com', 'user' => 'deploy'], 'staging' => ['host' => 'staging.example.com']], 'databases' => ['primary' => ['host' => 'db1.example.com', 'port' => 5432]]], + true + ], + 'nested object' => [ + 'servers.production', + ['host' => 'prod.example.com', 'user' => 'deploy'], + ['servers' => ['production' => ['host' => 'prod.example.com', 'user' => 'deploy'], 'staging' => ['host' => 'staging.example.com']], 'databases' => ['primary' => ['host' => 'db1.example.com', 'port' => 5432]]], + true + ], + 'top level collection' => [ + 'servers', + ['production' => ['host' => 'prod.example.com', 'user' => 'deploy'], 'staging' => ['host' => 'staging.example.com']], + ['servers' => ['production' => ['host' => 'prod.example.com', 'user' => 'deploy'], 'staging' => ['host' => 'staging.example.com']], 'databases' => ['primary' => ['host' => 'db1.example.com', 'port' => 5432]]], + true + ], + 'different collection port' => [ + 'databases.primary.port', + 5432, + ['servers' => ['production' => ['host' => 'prod.example.com', 'user' => 'deploy'], 'staging' => ['host' => 'staging.example.com']], 'databases' => ['primary' => ['host' => 'db1.example.com', 'port' => 5432]]], + true + ], + 'single segment' => [ + 'databases', + ['primary' => ['host' => 'db1.example.com', 'port' => 5432]], + ['servers' => ['production' => ['host' => 'prod.example.com', 'user' => 'deploy'], 'staging' => ['host' => 'staging.example.com']], 'databases' => ['primary' => ['host' => 'db1.example.com', 'port' => 5432]]], + true + ], + + // File exists - negative cases (non-existent paths) + 'non-existent deep path' => ['servers.web2.host', null, ['servers' => ['web1' => ['host' => 'example.com']]], true], + 'non-existent collection' => ['databases.primary', null, ['servers' => ['web1' => ['host' => 'example.com']]], true], + 'non-existent root' => ['missing', null, ['servers' => ['web1' => ['host' => 'example.com']]], true], + 'partial path match' => ['servers.web1.port', null, ['servers' => ['web1' => ['host' => 'example.com']]], true], + + // File doesn't exist + 'file not found' => ['servers', null, null, false], + ]); + + // + // HAS Operations + // ---- + + it('correctly identifies existing paths', function (string $path, bool $expected, bool $fileExists) { + // 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); + } + $this->service = new InventoryService($this->filesystem); + + // ACT + $result = $this->service->has($path); + + // 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], + ]); + + // + // DELETE Operations + // ---- + + 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->service = new InventoryService($this->filesystem); + + // ACT + $this->service->delete($path); + + // ASSERT - Operation completed without exception + expect(true)->toBeTrue(); + })->with([ + 'removes specific property' => [ + 'servers.web1.port', + ['servers' => ['web1' => ['host' => 'example.com', 'port' => 22], 'web2' => ['host' => 'test.com']]], + 'property removal' + ], + 'removes entire nested structure' => [ + 'servers.web1', + ['servers' => ['web1' => ['host' => 'example.com'], 'web2' => ['host' => 'test.com']]], + 'structure removal' + ], + 'handles non-existent path gracefully' => [ + 'servers.web2', + ['servers' => ['web1' => ['host' => 'example.com']]], + 'graceful handling' + ], + ]); + + // + // GETALL 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 + ], + ]); + + // + // Edge Cases + // ---- + + it('handles invalid YAML parsing gracefully', 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'); + + // ASSERT + expect($result)->toBeNull(); + }); + + // + // Integration Workflows + // ---- + + it('supports multi-step workflows', function (string $workflow) { + // ARRANGE + $this->filesystem = mockFilesystem(false); + $this->service = new InventoryService($this->filesystem); + + // ACT - Execute workflow steps + match ($workflow) { + 'server_management' => [ + $this->service->set('servers.production.host', 'prod.example.com'), + $this->service->set('servers.production.user', 'deploy'), + $this->service->set('servers.staging', ['host' => 'staging.example.com', 'user' => 'deploy']), + $this->service->set('databases.primary.host', 'db.example.com'), + ], + 'environment_config' => [ + $this->service->set('environments.production.database.host', 'production-db.example.com'), + $this->service->set('environments.staging.database.host', 'staging-db.example.com'), + $this->service->set('environments.production.app.debug', false), + $this->service->set('environments.staging.app.debug', true), + ], + }; + + // ASSERT - Operations completed without exception + expect(true)->toBeTrue(); + })->with([ + 'server_management', + 'environment_config', + ]); + + // + // Error Handling + // ---- + + it('handles error scenarios appropriately', function (string $scenario, string $expectedException) { + // ARRANGE & ACT & ASSERT + match ($scenario) { + 'directory_creation_failure' => [ + $filesystem = mockFilesystem(false, '', false, true, false), + $service = new InventoryService($filesystem), + expect(fn () => $service->set('servers.web1', 'value')) + ->toThrow(RuntimeException::class, 'Unable to create inventory directory') + ], + + 'file_write_failure' => [ + $filesystem = mockFilesystem(true, Yaml::dump([], 2, 4), false, false, true), + $service = new InventoryService($filesystem), + expect(fn () => $service->set('servers.web1', 'value')) + ->toThrow(RuntimeException::class, 'Failed to write inventory file') + ], + + 'yaml_parsing_error' => [ + $filesystem = mockFilesystem(true, "invalid: [\n - broken", false), // Malformed YAML + $service = new InventoryService($filesystem), + expect(fn () => $service->get('servers')) + ->toThrow(\Symfony\Component\Yaml\Exception\ParseException::class) + ], + }; + })->with([ + ['directory_creation_failure', RuntimeException::class], + ['file_write_failure', RuntimeException::class], + ['yaml_parsing_error', \Symfony\Component\Yaml\Exception\ParseException::class], + ]); +}); diff --git a/tests/Unit/VersionServiceTest.php b/tests/Unit/VersionServiceTest.php index 59927cc6..44c2f0f4 100644 --- a/tests/Unit/VersionServiceTest.php +++ b/tests/Unit/VersionServiceTest.php @@ -6,7 +6,6 @@ use Bigpixelrocket\DeployerPHP\Services\VersionService; describe('VersionService', function () { - it('returns version with correct fallback priority', function (string $packageName, string $fallback) { // ARRANGE $processFactory = new ProcessFactory();