diff --git a/.cursor/commands/create-branch-and-commits.md b/.cursor/commands/create-branch-and-commits.md new file mode 100644 index 00000000..ea391a19 --- /dev/null +++ b/.cursor/commands/create-branch-and-commits.md @@ -0,0 +1,27 @@ +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/. + +Keep the branch name short (≤ 50 chars) yet informative. Do not push, pull, or rebase. + +Examples: + +- feat/parser-add-php-84-attributes +- fix/ci-matrix-php-versions +- chore/deps-bump-composer-installers-2-3 + +Then, create one or more commits with suitable titles. + +Use Conventional Commits to group related changes into cohesive commits (commits should be independently meaningful). + +Keep titles short (≤ 72 chars), imperative, no trailing period. Do not push, pull, or rebase. + +Examples: + +- feat(parser): add support for PHP 8.4 attributes +- fix(ci): correct matrix PHP versions in build workflow +- chore(deps): bump composer/installers to ^2.3 + +Body (optional): explain motivation, context, and breaking changes (use BREAKING CHANGE:). diff --git a/.cursor/rules/02-tests.mdc b/.cursor/rules/02-tests.mdc index 32a406af..62bb1a01 100644 --- a/.cursor/rules/02-tests.mdc +++ b/.cursor/rules/02-tests.mdc @@ -13,6 +13,31 @@ alwaysApply: true - `composer pest` - run entire test suite in parallel, with coverage - `vendor/bin/pest $TEST_FILE` - run specific test file +### Dependency Injection in Tests + +**The DI Container rule applies to PRODUCTION code, not tests.** + +**✅ PREFERRED - Manual Instantiation (Unit Tests):** + +```php +$mockFs = mockFilesystem(true, 'content'); +$service = new EnvService(new FilesystemService($mockFs), new Dotenv()); +``` + +Benefits: Clear dependency wiring, easy mock injection, no container overhead. + +**✅ OPTIONAL - Container (Integration Tests):** + +```php +$container = new Container(); +$container->bind(Filesystem::class, fn() => $mockFs); +$service = $container->build(EnvService::class); +``` + +When to use: Testing multiple services together or verifying DI configuration. + +**Rule:** In unit tests, prefer `new ClassName()` with explicit mocks. Save the container for integration tests. + ### Test Minimalism **Target:** Keep test files under 1.8x the size of source code they test. diff --git a/app/Services/EnvService.php b/app/Services/EnvService.php index 286d4f00..1fecbdfa 100644 --- a/app/Services/EnvService.php +++ b/app/Services/EnvService.php @@ -5,7 +5,6 @@ namespace Bigpixelrocket\DeployerPHP\Services; use Symfony\Component\Dotenv\Dotenv; -use Symfony\Component\Filesystem\Filesystem; /** * Environment variable reader (first checks .env file then system environment variables) @@ -20,7 +19,7 @@ class EnvService private string $envFileStatus = ''; public function __construct( - private readonly Filesystem $filesystem, + private readonly FilesystemService $fs, private readonly Dotenv $dotenvParser, ) { } @@ -77,7 +76,7 @@ public function loadEnvFile(): void $path = $this->getEnvPath(); - if (!$this->filesystem->exists($path)) { + if (!$this->fs->exists($path)) { $this->envFileStatus = "No .env file found at {$path}"; return; } @@ -107,7 +106,7 @@ public function getEnvFileStatus(): string */ private function getEnvPath(): string { - return $this->envPath ?? rtrim((string) getcwd(), '/') . '/.env'; + return $this->envPath ?? rtrim($this->fs->getCwd(), '/') . '/.env'; } /** @@ -120,7 +119,7 @@ private function readDotenv(): void $path = $this->getEnvPath(); try { - $content = $this->filesystem->readFile($path); + $content = $this->fs->readFile($path); $parsed = $this->dotenvParser->parse($content, $path); foreach ($parsed as $k => $v) { diff --git a/app/Services/FilesystemService.php b/app/Services/FilesystemService.php new file mode 100644 index 00000000..5788cd37 --- /dev/null +++ b/app/Services/FilesystemService.php @@ -0,0 +1,106 @@ +exists('/path/to/file'); + * $content = $fs->readFile('/path/to/file'); + * $fs->dumpFile('/path/to/file', 'contents'); + * + * // Gap-filling methods (native PHP functions wrapped) + * $cwd = $fs->getCwd(); + * $isDir = $fs->isDirectory('/path'); + * $parent = $fs->getParentDirectory(__DIR__, 2); + */ +final readonly class FilesystemService +{ + public function __construct( + private Filesystem $fs, + ) { + } + + // + // Symfony Filesystem Wrappers + // ------------------------------------------------------------------------------- + + /** + * Check if a file or directory exists. + */ + public function exists(string $path): bool + { + return $this->fs->exists($path); + } + + /** + * Read file contents. + * + * @throws \RuntimeException If file cannot be read + */ + public function readFile(string $path): string + { + return $this->fs->readFile($path); + } + + /** + * Write contents to a file. + * + * @throws \RuntimeException If file cannot be written + */ + public function dumpFile(string $path, string $content): void + { + $this->fs->dumpFile($path, $content); + } + + // + // Gap-Filling Methods (Native PHP Functions) + // ------------------------------------------------------------------------------- + + /** + * Get current working directory. + * + * @throws \RuntimeException If current directory cannot be determined + */ + public function getCwd(): string + { + $cwd = getcwd(); + if ($cwd === false) { + throw new \RuntimeException('Unable to determine current working directory'); + } + + return $cwd; + } + + /** + * Check if path is a directory. + */ + public function isDirectory(string $path): bool + { + return $this->exists($path) && is_dir($path); + } + + /** + * Get parent directory path. + * + * @param int $levels Number of parent directories to traverse (default: 1) + */ + public function getParentDirectory(string $path, int $levels = 1): string + { + if ($levels < 1) { + throw new \InvalidArgumentException('Levels must be at least 1'); + } + + return dirname($path, $levels); + } +} diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php index e1fe108b..a5541c0e 100644 --- a/app/Services/InventoryService.php +++ b/app/Services/InventoryService.php @@ -4,7 +4,6 @@ namespace Bigpixelrocket\DeployerPHP\Services; -use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Yaml\Yaml; /** @@ -41,7 +40,7 @@ class InventoryService private ?string $inventoryFileStatus = null; public function __construct( - private readonly Filesystem $filesystem, + private readonly FilesystemService $fs, ) { } @@ -102,7 +101,7 @@ public function loadInventoryFile(): void $path = $this->getInventoryPath(); // Initialize empty inventory file if it doesn't exist - if (!$this->filesystem->exists($path)) { + if (!$this->fs->exists($path)) { $this->inventoryFileStatus = "Creating inventory file at {$path}"; $this->writeInventory(); } @@ -220,7 +219,7 @@ private function unsetByPath(array &$data, array $segments): bool */ private function getInventoryPath(): string { - return $this->inventoryPath ?? rtrim((string) getcwd(), '/') . '/inventory.yml'; + return $this->inventoryPath ?? rtrim($this->fs->getCwd(), '/') . '/inventory.yml'; } /** @@ -233,7 +232,7 @@ private function readInventory(): void $path = $this->getInventoryPath(); try { - $raw = $this->filesystem->readFile($path); + $raw = $this->fs->readFile($path); $parsed = Yaml::parse($raw); /** @var array $inventory */ @@ -257,7 +256,7 @@ private function writeInventory(): void try { $yaml = Yaml::dump($this->inventory, 2, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); - $this->filesystem->dumpFile($path, $yaml); + $this->fs->dumpFile($path, $yaml); } catch (\Throwable $e) { throw new \RuntimeException("Error writing inventory file at {$path}: " . $e->getMessage()); } diff --git a/app/Services/ProcessFactory.php b/app/Services/ProcessFactory.php index 16afed00..fa0e71c2 100644 --- a/app/Services/ProcessFactory.php +++ b/app/Services/ProcessFactory.php @@ -9,8 +9,13 @@ /** * Factory for creating Process instances with consistent timeout configuration. */ -final class ProcessFactory +final readonly class ProcessFactory { + public function __construct( + private FilesystemService $fs, + ) { + } + /** * Create a new Process with timeout. * @@ -22,7 +27,7 @@ public function create(array $command, string $cwd, ?float $timeout = 3.0): Proc throw new \InvalidArgumentException('Process command cannot be empty'); } - if (!is_dir($cwd)) { + if (!$this->fs->isDirectory($cwd)) { throw new \InvalidArgumentException("Invalid working directory: {$cwd}"); } diff --git a/app/Services/SSHService.php b/app/Services/SSHService.php index 83305999..c74ae61d 100644 --- a/app/Services/SSHService.php +++ b/app/Services/SSHService.php @@ -8,7 +8,6 @@ use phpseclib3\Crypt\PublicKeyLoader; use phpseclib3\Net\SFTP; use phpseclib3\Net\SSH2; -use Symfony\Component\Filesystem\Filesystem; /** * SSH and SFTP operations for remote server management. @@ -42,7 +41,7 @@ class SSHService { public function __construct( private readonly EnvService $envService, - private readonly Filesystem $filesystem, + private readonly FilesystemService $fs, ) { } @@ -96,11 +95,11 @@ public function executeCommand(string $host, int $port, string $username, string */ public function executeScript(string $host, int $port, string $username, string $scriptPath, ?string $privateKeyPath = null): array { - if (!$this->filesystem->exists($scriptPath)) { + if (!$this->fs->exists($scriptPath)) { throw new \RuntimeException("Script file does not exist: {$scriptPath}"); } - $scriptContents = $this->filesystem->readFile($scriptPath); + $scriptContents = $this->fs->readFile($scriptPath); $ssh = $this->createConnection($host, $port, $username, $privateKeyPath); @@ -128,14 +127,14 @@ public function executeScript(string $host, int $port, string $username, string */ public function uploadFile(string $host, int $port, string $username, string $localPath, string $remotePath, ?string $privateKeyPath = null): void { - if (!$this->filesystem->exists($localPath)) { + if (!$this->fs->exists($localPath)) { throw new \RuntimeException("Local file does not exist: {$localPath}"); } $sftp = $this->createSFTPConnection($host, $port, $username, $privateKeyPath); try { - $contents = $this->filesystem->readFile($localPath); + $contents = $this->fs->readFile($localPath); $uploaded = $sftp->put($remotePath, $contents); if (!$uploaded) { @@ -163,7 +162,7 @@ public function downloadFile(string $host, int $port, string $username, string $ throw new \RuntimeException("Error downloading file from {$remotePath} on {$host}"); } - $this->filesystem->dumpFile($localPath, is_string($contents) ? $contents : ''); + $this->fs->dumpFile($localPath, is_string($contents) ? $contents : ''); } catch (\Throwable $e) { throw new \RuntimeException("Error downloading file from {$remotePath} on {$host}: " . $e->getMessage()); } finally { @@ -260,11 +259,11 @@ private function loadPrivateKey(?string $privateKeyPath): PrivateKey throw new \RuntimeException('No SSH private key found. Provide a key path or place a key at ~/.ssh/id_ed25519 or ~/.ssh/id_rsa'); } - if (!$this->filesystem->exists($resolvedKeyPath)) { + if (!$this->fs->exists($resolvedKeyPath)) { throw new \RuntimeException("SSH key does not exist: {$resolvedKeyPath}"); } - $keyContents = $this->filesystem->readFile($resolvedKeyPath); + $keyContents = $this->fs->readFile($resolvedKeyPath); try { $key = PublicKeyLoader::load($keyContents); @@ -306,7 +305,7 @@ private function resolvePrivateKeyPath(?string $path): ?string // Return first existing candidate foreach ($candidates as $candidate) { - if ($this->filesystem->exists($candidate)) { + if ($this->fs->exists($candidate)) { return $candidate; } } diff --git a/app/Services/VersionService.php b/app/Services/VersionService.php index b3030e7c..e6667400 100644 --- a/app/Services/VersionService.php +++ b/app/Services/VersionService.php @@ -19,6 +19,7 @@ class VersionService { public function __construct( private readonly ProcessFactory $processFactory, + private readonly FilesystemService $fs, private readonly string $packageName = 'bigpixelrocket/deployer-php', private readonly string $fallbackVersion = 'dev-main' ) { @@ -72,7 +73,7 @@ public function getVersionFromComposer(): ?string */ public function getVersionFromGit(?string $projectRoot = null): ?string { - $projectRoot ??= dirname(__DIR__, 2); + $projectRoot ??= $this->fs->getParentDirectory(__DIR__, 2); // Check if we're in a git repository if (!$this->isGitRepository($projectRoot)) { @@ -104,7 +105,7 @@ public function getVersionFromGit(?string $projectRoot = null): ?string */ public function isGitRepository(string $projectRoot): bool { - return is_dir($projectRoot . '/.git'); + return $this->fs->isDirectory($projectRoot . '/.git'); } /** diff --git a/tests/TestHelpers.php b/tests/TestHelpers.php index 3b8dbd1d..6805bd06 100644 --- a/tests/TestHelpers.php +++ b/tests/TestHelpers.php @@ -3,10 +3,14 @@ declare(strict_types=1); use Bigpixelrocket\DeployerPHP\Services\EnvService; +use Bigpixelrocket\DeployerPHP\Services\FilesystemService; use Bigpixelrocket\DeployerPHP\Services\InventoryService; +use Bigpixelrocket\DeployerPHP\Services\ProcessFactory; +use Bigpixelrocket\DeployerPHP\Services\VersionService; use Symfony\Component\Dotenv\Dotenv; -use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Exception\IOException; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Yaml\Yaml; if (!function_exists('setEnv')) { /** @@ -27,7 +31,7 @@ function setEnv(string $key, ?string $value): void if (!function_exists('mockFilesystem')) { /** - * Create a mock filesystem for testing with comprehensive error simulation and in-memory storage. + * Create a mock filesystem for testing with error simulation and in-memory storage. */ function mockFilesystem( bool $exists = true, @@ -38,8 +42,8 @@ function mockFilesystem( string $initialPath = '.deployer/inventory.yml' ): Filesystem { return new class ($exists, $content, $throwOnRead, $throwOnMkdir, $throwOnDump, $initialPath) extends Filesystem { - private array $fileSystem = []; - private bool $dirExists = true; + private array $files = []; + private array $directories = []; public function __construct( private readonly bool $initialExists, @@ -50,23 +54,9 @@ public function __construct( private readonly string $initialPath ) { if ($this->initialExists) { - $this->fileSystem[$this->initialPath] = $this->initialContent; - } - $this->dirExists = !$this->throwOnMkdir; - } - - private function normalizePath(string $path): string - { - return str_replace('\\', '/', $path); - } - - private function getTargetKey(string $path): string - { - $normalized = $this->normalizePath($path); - if ($normalized === $this->initialPath || str_ends_with($normalized, '/' . $this->initialPath)) { - return $this->initialPath; + $this->files[$this->initialPath] = $this->initialContent; } - return $normalized; + $this->directories = $this->throwOnMkdir ? [] : ['.deployer']; } public function exists(string|iterable $files): bool @@ -80,14 +70,25 @@ public function exists(string|iterable $files): bool return true; } - // Handle directory checks - $normalized = $this->normalizePath($files); - if (str_ends_with($normalized, '.deployer')) { - return $this->dirExists; + // Check files (direct match or path ends with stored key) + if (isset($this->files[$files])) { + return true; } - $targetKey = $this->getTargetKey($files); - return isset($this->fileSystem[$targetKey]); + foreach (array_keys($this->files) as $storedPath) { + if (str_ends_with($files, (string) $storedPath)) { + return true; + } + } + + // Check directories (match exact path only) + foreach ($this->directories as $dir) { + if (rtrim($files, '/\\') === rtrim((string) $dir, '/\\')) { + return true; + } + } + + return false; } public function readFile(string $filename): string @@ -96,22 +97,28 @@ public function readFile(string $filename): string throw new IOException('Permission denied', 0, null, $filename); } - $targetKey = $this->getTargetKey($filename); - if (!isset($this->fileSystem[$targetKey])) { - throw new IOException("File does not exist: {$filename}", 0, null, $filename); + // Try direct match first + if (isset($this->files[$filename])) { + return $this->files[$filename]; } - return $this->fileSystem[$targetKey]; + // Try path ending match + foreach ($this->files as $storedPath => $content) { + if (str_ends_with($filename, (string) $storedPath)) { + return $content; + } + } + + throw new IOException("File does not exist: {$filename}", 0, null, $filename); } - public function mkdir($dirs, int $mode = 0777): void + public function mkdir(string|iterable $dirs, int $mode = 0777): void { if ($this->throwOnMkdir) { throw new IOException('Permission denied', 0, null, (string) $dirs); } - unset($dirs, $mode); - $this->dirExists = true; + $this->directories[] = (string) $dirs; } public function dumpFile(string $filename, $content): void @@ -119,8 +126,8 @@ public function dumpFile(string $filename, $content): void if ($this->throwOnDump) { throw new IOException('Write failed', 0, null, $filename); } - $targetKey = $this->getTargetKey($filename); - $this->fileSystem[$targetKey] = $content; + + $this->files[$filename] = $content; } }; } @@ -128,22 +135,97 @@ public function dumpFile(string $filename, $content): void if (!function_exists('mockEnvService')) { /** - * Create a mock EnvService for testing. + * Create a mock EnvService for testing with configurable filesystem behavior. */ - function mockEnvService(bool $hasFile = true): EnvService - { - $content = $hasFile ? 'API_KEY=test_value' : ''; - return new EnvService(mockFilesystem($hasFile, $content, false, false, false, '.env'), new Dotenv()); + 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 testing. + * Create a mock InventoryService for testing with configurable filesystem behavior. + * Accepts either array data (auto-converts to YAML) or raw string content. + */ + function mockInventoryService( + bool $fileExists = true, + array|string $data = '', + bool $throwOnRead = false, + bool $throwOnWrite = false + ): InventoryService { + // Convert array data to YAML + if (is_array($data)) { + $fileContent = empty($data) ? '' : Yaml::dump($data, 2, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); + } else { + $defaultContent = 'servers:' . PHP_EOL . ' web1:' . PHP_EOL . ' host: example.com'; + $fileContent = $data ?: ($fileExists ? $defaultContent : ''); + } + + $mockFs = mockFilesystem($fileExists, $fileContent, $throwOnRead, false, $throwOnWrite, 'inventory.yml'); + $filesystemService = new FilesystemService($mockFs); + return new InventoryService($filesystemService); + } +} + +if (!function_exists('mockFilesystemService')) { + /** + * Create a FilesystemService with a mock Filesystem for testing. */ - function mockInventoryService(bool $hasFile = true): InventoryService + 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); + } +} + +if (!function_exists('mockProcessFactory')) { + /** + * Create a ProcessFactory for testing. + * + * Uses real Filesystem since directory validation requires is_dir() checks. + * Tests should use real directories (e.g., __DIR__, sys_get_temp_dir()). + */ + function mockProcessFactory(): ProcessFactory { - $content = $hasFile ? 'servers:' . PHP_EOL . ' web1:' . PHP_EOL . ' host: example.com' : ''; - return new InventoryService(mockFilesystem($hasFile, $content, false, false, false, '.deployer/inventory.yml')); + $filesystemService = new FilesystemService(new Filesystem()); + return new ProcessFactory($filesystemService); + } +} + +if (!function_exists('mockVersionService')) { + /** + * Create a VersionService for testing with configurable package name and fallback. + * + * Uses real Filesystem and ProcessFactory since git operations require real directory checks. + */ + function mockVersionService( + ?string $packageName = null, + ?string $fallback = null + ): VersionService { + $filesystemService = new FilesystemService(new Filesystem()); + $processFactory = new ProcessFactory($filesystemService); + + // Conditionally pass parameters to use VersionService defaults + if ($packageName !== null && $fallback !== null) { + return new VersionService($processFactory, $filesystemService, $packageName, $fallback); + } + + if ($packageName !== null) { + return new VersionService($processFactory, $filesystemService, $packageName); + } + + return new VersionService($processFactory, $filesystemService); } } diff --git a/tests/Unit/Services/EnvServiceTest.php b/tests/Unit/Services/EnvServiceTest.php index f6f1f923..80b36341 100644 --- a/tests/Unit/Services/EnvServiceTest.php +++ b/tests/Unit/Services/EnvServiceTest.php @@ -2,9 +2,6 @@ declare(strict_types=1); -use Bigpixelrocket\DeployerPHP\Services\EnvService; -use Symfony\Component\Dotenv\Dotenv; - require_once __DIR__ . '/../../TestHelpers.php'; // @@ -21,10 +18,7 @@ it('reports correct status for different .env file scenarios', function ($fileExists, $fileContent, $expectsException, $expectedStatusPattern) { // ARRANGE - $service = new EnvService( - mockFilesystem($fileExists, $fileContent, $expectsException, false, false, '.env'), - new Dotenv() - ); + $service = mockEnvService($fileExists, $fileContent, $expectsException); // ACT & ASSERT if ($expectsException) { @@ -54,10 +48,7 @@ foreach ($env as $key => $value) { setEnv($key, $value); } - $service = new EnvService( - mockFilesystem(!empty($fileContent), $fileContent, $fileError, false, false, '.env'), - new Dotenv() - ); + $service = mockEnvService(!empty($fileContent), $fileContent, $fileError); $service->loadEnvFile(); // ACT @@ -86,10 +77,7 @@ it('handles required vs optional parameters', function ($keys, $required, $expectsException, $expectedMessage) { // ARRANGE - $service = new EnvService( - mockFilesystem(false, '', false, false, false, '.env'), - new Dotenv() - ); + $service = mockEnvService(false, ''); $service->loadEnvFile(); // ACT & ASSERT diff --git a/tests/Unit/Services/FilesystemServiceTest.php b/tests/Unit/Services/FilesystemServiceTest.php new file mode 100644 index 00000000..c11d70cf --- /dev/null +++ b/tests/Unit/Services/FilesystemServiceTest.php @@ -0,0 +1,128 @@ +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 + expect($result)->toBeString()->not->toBeEmpty(); + }); + + 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/InventoryServiceTest.php b/tests/Unit/Services/InventoryServiceTest.php index 240a7cd9..6f4cdca4 100644 --- a/tests/Unit/Services/InventoryServiceTest.php +++ b/tests/Unit/Services/InventoryServiceTest.php @@ -2,9 +2,6 @@ declare(strict_types=1); -use Bigpixelrocket\DeployerPHP\Services\InventoryService; -use Symfony\Component\Yaml\Yaml; - require_once __DIR__ . '/../../TestHelpers.php'; // @@ -13,8 +10,7 @@ describe('InventoryService', function () { beforeEach(function () { - $this->filesystem = mockFilesystem(true, '', false, false, false, 'inventory.yml'); - $this->service = new InventoryService($this->filesystem); + $this->service = mockInventoryService(true, ''); }); // @@ -23,14 +19,7 @@ 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, false, false, 'inventory.yml'); - } else { - $this->filesystem = mockFilesystem(false, '', false, false, false, 'inventory.yml'); - } - - $this->service = new InventoryService($this->filesystem); + $this->service = mockInventoryService($fileExists, $existingData ?? []); $this->service->loadInventoryFile(); // ACT @@ -59,14 +48,7 @@ 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, false, false, 'inventory.yml'); - } else { - $this->filesystem = mockFilesystem(false, '', false, false, false, 'inventory.yml'); - } - - $this->service = new InventoryService($this->filesystem); + $this->service = mockInventoryService($fileExists, $inventoryData ?? []); $this->service->loadInventoryFile(); // ACT @@ -124,9 +106,7 @@ it('returns default value when path does not exist', function (string $path, mixed $default, mixed $expected) { // ARRANGE $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 = mockInventoryService(true, $inventoryData); $this->service->loadInventoryFile(); // ACT @@ -149,9 +129,7 @@ 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, false, false, 'inventory.yml'); - $this->service = new InventoryService($this->filesystem); + $this->service = mockInventoryService(true, $inventoryData); $this->service->loadInventoryFile(); // ACT @@ -190,8 +168,7 @@ it('throws RuntimeException when file write fails during initialization', function () { // ARRANGE - $filesystem = mockFilesystem(false, '', false, false, true, 'inventory.yml'); - $service = new InventoryService($filesystem); + $service = mockInventoryService(false, '', false, true); // ACT & ASSERT expect(fn () => $service->loadInventoryFile()) @@ -200,8 +177,7 @@ it('throws RuntimeException when file write fails during set operation', function () { // ARRANGE - $filesystem = mockFilesystem(true, Yaml::dump(['existing' => 'data'], 2, 4), false, false, true, 'inventory.yml'); - $service = new InventoryService($filesystem); + $service = mockInventoryService(true, ['existing' => 'data'], false, true); $service->loadInventoryFile(); // ACT & ASSERT @@ -211,8 +187,7 @@ it('throws RuntimeException when file read fails', function () { // ARRANGE - $filesystem = mockFilesystem(true, 'content', true, false, false, 'inventory.yml'); - $service = new InventoryService($filesystem); + $service = mockInventoryService(true, 'content', true); // ACT & ASSERT expect(fn () => $service->loadInventoryFile()) @@ -221,8 +196,7 @@ it('throws RuntimeException when attempting write before initialization', function () { // ARRANGE - $filesystem = mockFilesystem(false, '', false, false, false, 'inventory.yml'); - $service = new InventoryService($filesystem); + $service = mockInventoryService(false, ''); // ACT & ASSERT expect(fn () => $service->set('servers.web1', 'value')) @@ -233,10 +207,9 @@ // Inventory file status // ------------------------------------------------------------------------------- - it('reports correct inventory file status for different scenarios', function (bool $fileExists, string $fileContent, bool $fileError, bool $fileWriteError, bool $expectsException, ?string $expectedStatusPattern) { + it('reports correct inventory file status for different scenarios', function (bool $fileExists, array|string $data, bool $fileError, bool $fileWriteError, bool $expectsException, ?string $expectedStatusPattern) { // ARRANGE - $filesystem = mockFilesystem($fileExists, $fileContent, $fileError, false, $fileWriteError, 'inventory.yml'); - $service = new InventoryService($filesystem); + $service = mockInventoryService($fileExists, $data, $fileError, $fileWriteError); // ACT & ASSERT if ($expectsException) { @@ -249,19 +222,19 @@ } })->with([ // File exists with content - [true, Yaml::dump(['servers' => ['web1' => ['host' => 'example.com', 'port' => 22]]], 2, 4), false, false, false, '/^Reading inventory from .+\.yml$/'], + [true, ['servers' => ['web1' => ['host' => 'example.com', 'port' => 22]]], false, false, false, '/^Reading inventory from .+\.yml$/'], // File exists with single item - [true, Yaml::dump(['single_key' => 'value'], 2, 4), false, false, false, '/^Reading inventory from .+\.yml$/'], + [true, ['single_key' => 'value'], false, false, false, '/^Reading inventory from .+\.yml$/'], // File exists but is empty - [true, Yaml::dump([], 2, 4), false, false, false, '/^Reading inventory from .+\.yml$/'], + [true, [], false, false, false, '/^Reading inventory from .+\.yml$/'], // File exists with complex structure - [true, Yaml::dump(['environments' => ['prod' => ['db' => ['host' => 'prod-db']]]], 2, 4), false, false, false, '/^Reading inventory from .+\.yml$/'], + [true, ['environments' => ['prod' => ['db' => ['host' => 'prod-db']]]], 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], + [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/ProcessFactoryTest.php b/tests/Unit/Services/ProcessFactoryTest.php index 9a97f15b..9491d757 100644 --- a/tests/Unit/Services/ProcessFactoryTest.php +++ b/tests/Unit/Services/ProcessFactoryTest.php @@ -2,12 +2,13 @@ declare(strict_types=1); -use Bigpixelrocket\DeployerPHP\Services\ProcessFactory; + +require_once __DIR__ . '/../../TestHelpers.php'; describe('ProcessFactory', function () { beforeEach(function () { - $this->factory = new ProcessFactory(); $this->validCwd = __DIR__; + $this->factory = mockProcessFactory(); }); it('configures process timeout correctly', function (?float $inputTimeout, ?float $expectedTimeout) { diff --git a/tests/Unit/Services/SSHServiceTest.php b/tests/Unit/Services/SSHServiceTest.php index 1a4601e4..5c481237 100644 --- a/tests/Unit/Services/SSHServiceTest.php +++ b/tests/Unit/Services/SSHServiceTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Bigpixelrocket\DeployerPHP\Services\FilesystemService; use Bigpixelrocket\DeployerPHP\Services\SSHService; require_once __DIR__ . '/../../TestHelpers.php'; @@ -25,9 +26,9 @@ it('throws helpful message when no SSH key is found', function () { // ARRANGE - $filesystem = mockFilesystem(false, '', false, false, false, 'none'); + $filesystemService = mockFilesystemService(false, '', false, false, false, 'none'); $envService = mockEnvService(false); - $service = new SSHService($envService, $filesystem); + $service = new SSHService($envService, $filesystemService); // ACT & ASSERT try { @@ -43,9 +44,9 @@ it('resolves user-provided key path with tilde expansion', function () { // ARRANGE - $filesystem = mockFilesystem(true, '', false, false, false, '/home/testuser/.ssh/custom_key'); + $filesystemService = mockFilesystemService(true, '', false, false, false, '/home/testuser/.ssh/custom_key'); $envService = mockEnvService(false); - $service = new SSHService($envService, $filesystem); + $service = new SSHService($envService, $filesystemService); // ACT & ASSERT - Test through public API $reflection = new \ReflectionClass($service); @@ -57,11 +58,12 @@ it('prioritizes provided path over defaults and returns first existing', function () { // ARRANGE - $filesystem = mockFilesystem(); - $filesystem->dumpFile('/home/testuser/custom/id_rsa', 'valid_key'); - $filesystem->dumpFile('/home/testuser/.ssh/id_ed25519', 'ed_key'); + $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, $filesystem); + $service = new SSHService($envService, $filesystemService); // ACT $reflection = new \ReflectionClass($service); @@ -74,10 +76,11 @@ it('falls back to default locations when no provided path', function () { // ARRANGE - $filesystem = mockFilesystem(); - $filesystem->dumpFile('/home/testuser/.ssh/id_rsa', 'valid_key'); + $mockFs = mockFilesystem(); + $mockFs->dumpFile('/home/testuser/.ssh/id_rsa', 'valid_key'); + $filesystemService = new FilesystemService($mockFs); $envService = mockEnvService(false); - $service = new SSHService($envService, $filesystem); + $service = new SSHService($envService, $filesystemService); // ACT $reflection = new \ReflectionClass($service); @@ -90,8 +93,10 @@ it('expands tilde in paths correctly', function () { // ARRANGE + $mockFs = mockFilesystem(); + $filesystemService = new FilesystemService($mockFs); $envService = mockEnvService(false); - $service = new SSHService($envService, mockFilesystem()); + $service = new SSHService($envService, $filesystemService); // ACT $reflection = new \ReflectionClass($service); @@ -109,9 +114,10 @@ it('throws when key content cannot be parsed as private key', function () { // ARRANGE $invalidContent = 'invalid_key_content_not_ssh'; - $filesystem = mockFilesystem(true, $invalidContent, false, false, false, '/home/testuser/.ssh/id_rsa'); + $mockFs = mockFilesystem(true, $invalidContent, false, false, false, '/home/testuser/.ssh/id_rsa'); + $filesystemService = new FilesystemService($mockFs); $envService = mockEnvService(false); - $service = new SSHService($envService, $filesystem); + $service = new SSHService($envService, $filesystemService); // ACT & ASSERT expect(fn () => $service->assertCanConnect('example.com', 22, 'deployer', '/home/testuser/.ssh/id_rsa')) @@ -124,9 +130,10 @@ it('validates script file exists before execution', function () { // ARRANGE - $filesystem = mockFilesystem(false, '', false, false, false, './missing.sh'); + $mockFs = mockFilesystem(false, '', false, false, false, './missing.sh'); + $filesystemService = new FilesystemService($mockFs); $envService = mockEnvService(false); - $service = new SSHService($envService, $filesystem); + $service = new SSHService($envService, $filesystemService); // ACT & ASSERT expect(fn () => $service->executeScript('host', 22, 'user', './missing.sh')) @@ -135,9 +142,10 @@ it('validates local file exists before upload', function () { // ARRANGE - $filesystem = mockFilesystem(false, '', false, false, false, './missing.txt'); + $mockFs = mockFilesystem(false, '', false, false, false, './missing.txt'); + $filesystemService = new FilesystemService($mockFs); $envService = mockEnvService(false); - $service = new SSHService($envService, $filesystem); + $service = new SSHService($envService, $filesystemService); // ACT & ASSERT expect(fn () => $service->uploadFile('host', 22, 'user', './missing.txt', '/remote/file.txt')) @@ -146,9 +154,10 @@ it('includes file path in error messages', function (string $method, array $args, string $expectedPath) { // ARRANGE - $filesystem = mockFilesystem(false, '', false, false, false, $expectedPath); + $mockFs = mockFilesystem(false, '', false, false, false, $expectedPath); + $filesystemService = new FilesystemService($mockFs); $envService = mockEnvService(false); - $service = new SSHService($envService, $filesystem); + $service = new SSHService($envService, $filesystemService); // ACT & ASSERT try { diff --git a/tests/Unit/Services/VersionServiceTest.php b/tests/Unit/Services/VersionServiceTest.php index 44c2f0f4..69b281f3 100644 --- a/tests/Unit/Services/VersionServiceTest.php +++ b/tests/Unit/Services/VersionServiceTest.php @@ -2,14 +2,12 @@ declare(strict_types=1); -use Bigpixelrocket\DeployerPHP\Services\ProcessFactory; -use Bigpixelrocket\DeployerPHP\Services\VersionService; +require_once __DIR__ . '/../../TestHelpers.php'; describe('VersionService', function () { it('returns version with correct fallback priority', function (string $packageName, string $fallback) { // ARRANGE - $processFactory = new ProcessFactory(); - $service = new VersionService($processFactory, $packageName, $fallback); + $service = mockVersionService($packageName, $fallback); // ACT $version = $service->getVersion(); @@ -39,8 +37,7 @@ mkdir($tempDir . '/.git'); } - $processFactory = new ProcessFactory(); - $service = new VersionService($processFactory); + $service = mockVersionService(); // ACT $result = $service->isGitRepository($tempDir); @@ -60,9 +57,8 @@ it('handles git command failures gracefully for all git methods', function (string $method) { // ARRANGE - $processFactory = new ProcessFactory(); - $service = new VersionService($processFactory); $invalidPath = '/absolutely/non/existent/path'; + $service = mockVersionService(); // ACT $result = $service->$method($invalidPath); @@ -77,8 +73,7 @@ it('returns null for non-existent composer packages', function () { // ARRANGE - $processFactory = new ProcessFactory(); - $service = new VersionService($processFactory, 'absolutely/non/existent/package'); + $service = mockVersionService('absolutely/non/existent/package'); // ACT $result = $service->getVersionFromComposer(); diff --git a/tests/Unit/TestHelpersTest.php b/tests/Unit/TestHelpersTest.php new file mode 100644 index 00000000..08f079ee --- /dev/null +++ b/tests/Unit/TestHelpersTest.php @@ -0,0 +1,199 @@ +exists($checkPath))->toBe($expected, $description); + })->with([ + 'directory exists' => [true, 'content', '.deployer/inventory.yml', '.deployer', true, 'directory should exist'], + 'directory with trailing slash' => [true, 'content', '.deployer/inventory.yml', '.deployer/', true, 'directory with trailing slash should exist'], + 'existing file' => [true, 'content', '.deployer/inventory.yml', '.deployer/inventory.yml', true, 'existing file should exist'], + 'non-existent file in existing dir' => [true, 'content', '.deployer/inventory.yml', '.deployer/missing.yml', false, 'non-existent file should not exist'], + 'directory exists when file does not' => [false, '', '.deployer/inventory.yml', '.deployer', true, 'directory should exist'], + 'non-existent file (no substring match)' => [false, '', '.deployer/inventory.yml', '.deployer/inventory.yml', false, 'non-existent file should not exist even if directory matches'], + 'different path no substring match' => [false, '', '.deployer/inventory.yml', '/path/to/.deployer/config.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('servers.web1.host'); + + // ASSERT + expect($result)->toBe('example.com'); + })->with([ + 'array data' => [['servers' => ['web1' => ['host' => 'example.com']]]], + 'string data' => ['servers:' . PHP_EOL . ' web1:' . PHP_EOL . ' host: example.com'], + ]); +}); + +describe('mockFilesystemService', function () { + it('creates FilesystemService with mock filesystem', function () { + // ARRANGE + $service = mockFilesystemService(fileExists: true, fileContent: 'test content', filePath: 'test.txt'); + + // ACT + $exists = $service->exists('test.txt'); + $content = $service->readFile('test.txt'); + + // ASSERT + expect($exists)->toBeTrue() + ->and($content)->toBe('test content'); + }); +}); + +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], + ]); +});