diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index 87d66ba..0c69e45 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -37,4 +37,4 @@ jobs: composer update --${{ matrix.stability }} --prefer-dist --no-interaction - name: Run tests - run: vendor/bin/phpunit + run: vendor/bin/pest diff --git a/.gitignore b/.gitignore index f999f25..540e263 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ composer.lock .DS_Store .phpunit.result.cache .php-cs-fixer.cache +.phpunit.cache +.claude diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..14a14b7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,54 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +composer install # Install dependencies +composer test # Run tests (alias for vendor/bin/pest) +vendor/bin/pest # Run tests directly +vendor/bin/pint # Fix code style (Laravel Pint) +``` + +Run a single test: +```bash +vendor/bin/pest --filter "test description" +``` + +## Architecture + +This is a **Laravel package** (`aerni/sync`) that provides artisan commands to sync files/folders between environments using `rsync` over SSH. + +### Core Concepts + +- **Remotes**: Named SSH targets (user, host, root, port, read_only) defined in `config/sync.php` +- **Recipes**: Named groups of file paths to sync, also defined in `config/sync.php` +- **Operations**: `push` (local → remote) or `pull` (remote → local) + +### Key Files + +- `src/SyncServiceProvider.php` — Registers the three artisan commands and publishes `config/sync.php` +- `src/SyncCommand.php` — Value object representing a single rsync invocation; implements `Arrayable` and `Stringable` to produce the shell command string +- `src/PathGenerator.php` — Builds local and remote paths; detects if remote host equals local host by calling `api.ipify.org` +- `src/Commands/BaseCommand.php` — Shared base for all three commands; handles argument parsing, interactive prompts (via Laravel Prompts), validation, and building the collection of `SyncCommand` objects +- `src/Commands/Sync.php` — Executes rsync via `Illuminate\Support\Facades\Process` +- `src/Commands/SyncList.php` — Displays a table of origin/target/options/port +- `src/Commands/SyncCommands.php` — Prints the raw rsync shell commands + +### Command Signature Pattern + +`BaseCommand` appends shared arguments/options to the child command's `$signature`: +``` +{operation} {remote} {recipe} {--O|option=*} {--D|dry} +``` +All three commands (`sync`, `sync:list`, `sync:commands`) share this signature via inheritance. + +### Testing + +Tests use [Pest 3](https://pestphp.com) with [Orchestra Testbench](https://github.com/orchestral/testbench). `tests/Pest.php` configures all tests to extend `Orchestra\Testbench\TestCase` automatically, so test files just use `it()` / `expect()` directly without any class boilerplate. + +### CI + +- **run-tests.yaml**: Runs PHPUnit across PHP 8.2/8.3/8.4 × Laravel 11/12 × prefer-lowest/prefer-stable +- **pint.yaml**: Auto-commits style fixes via Laravel Pint on every push diff --git a/composer.json b/composer.json index 45f439a..dd9bcea 100644 --- a/composer.json +++ b/composer.json @@ -17,9 +17,11 @@ "laravel/prompts": "^0.1.17 || ^0.2.0 || ^0.3.0" }, "require-dev": { - "nunomaduro/collision": "^8.1", + "laravel/pint": "^1.29", + "nunomaduro/collision": "^8.8", "orchestra/testbench": "^9.0 || ^10.0", - "phpunit/phpunit": "^10.0 || ^11.0" + "pestphp/pest": "^3.0", + "pestphp/pest-plugin-laravel": "^3.0" }, "autoload": { "psr-4": { @@ -32,7 +34,7 @@ } }, "scripts": { - "test": "vendor/bin/phpunit" + "test": "vendor/bin/pest" }, "extra": { "laravel": { @@ -42,7 +44,10 @@ } }, "config": { - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true + } }, "prefer-stable": true, "minimum-stability": "dev" diff --git a/phpunit.xml b/phpunit.xml index 78049a5..0a1c0a0 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,6 +1,9 @@ - - + ./tests diff --git a/src/Commands/BaseCommand.php b/src/Commands/BaseCommand.php index 6a72118..f4778ee 100644 --- a/src/Commands/BaseCommand.php +++ b/src/Commands/BaseCommand.php @@ -10,18 +10,40 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use function Laravel\Prompts\multiselect; use function Laravel\Prompts\select; class BaseCommand extends Command implements PromptsForMissingInput { + public const RSYNC_OPTIONS = [ + '--archive' => 'Archive mode (equals -rlptgoD)', + '--compress' => 'Compress file data during transfer', + '--verbose' => 'Increase verbosity', + '--progress' => 'Show progress during transfer', + '--human-readable' => 'Output numbers in a human-readable format', + '--delete' => 'Delete extraneous files from destination', + '--delete-after' => 'Delete after transfer, not during', + '--dry-run' => 'Perform a trial run with no changes made', + '--stats' => 'Give some file-transfer stats', + '--partial' => 'Keep partially transferred files', + '--checksum' => 'Skip based on checksum, not mod-time & size', + '--update' => 'Skip files newer on the receiver', + '--copy-links' => 'Transform symlinks into referent files', + '--no-perms' => 'Do not preserve permissions', + '--no-owner' => 'Do not preserve owner', + '--no-group' => 'Do not preserve group', + '--itemize-changes' => 'Output a change-summary for all updates', + ]; + public function __construct() { $baseSignature = ' {operation : Choose if you want to push or pull} {remote : The remote you want to sync with} - {recipe : The recipe defining the paths to sync} + {recipe?* : The recipes defining the paths to sync} {--O|option=* : An rsync option to use} {--D|dry : Perform a dry run of the sync} '; @@ -42,23 +64,53 @@ protected function promptForMissingArgumentsUsing(): array label: 'Choose the remote you want to sync with', options: array_keys($this->remotes()), ), - 'recipe' => fn () => select( - label: 'Choose the recipe defining the paths to sync', - options: array_keys($this->recipes()), - ), ]; } + protected function interact(InputInterface $input, OutputInterface $output): void + { + parent::interact($input, $output); + + $isInteractive = empty($input->getArgument('recipe')); + + if ($isInteractive) { + $input->setArgument('recipe', multiselect( + label: 'Choose the recipes defining the paths to sync', + options: array_keys($this->recipes()), + required: 'You must select at least one recipe.', + )); + + if (empty($input->getOption('option'))) { + $configOptions = config('sync.options', []); + $default = array_values(array_intersect($configOptions, array_keys(self::RSYNC_OPTIONS))); + + $input->setOption('option', multiselect( + label: 'Choose rsync options', + options: self::RSYNC_OPTIONS, + default: $default, + hint: 'Config defaults are pre-selected. Use space to toggle.', + )); + } + } + } + protected function validate(): void { - Validator::validate($this->arguments(), [ + Validator::validate([ + 'operation' => $this->operation(), + 'remote' => $this->argument('remote'), + 'recipe' => $this->selectedRecipes(), + ], [ 'operation' => 'required|in:push,pull', 'remote' => ['required', Rule::in(array_keys($this->remotes()))], - 'recipe' => ['required', Rule::in(array_keys($this->recipes()))], + 'recipe' => 'required|array|min:1', + 'recipe.*' => [Rule::in(array_keys($this->recipes()))], ], [ 'operation.in' => 'The :attribute [:input] does not exists. Valid values are [push] or [pull].', 'remote.in' => 'The :attribute [:input] does not exists. Please choose a valid remote.', - 'recipe.in' => 'The :attribute [:input] does not exists. Please choose a valid recipe.', + 'recipe.required' => 'You must provide at least one recipe.', + 'recipe.min' => 'You must provide at least one recipe.', + 'recipe.*.in' => 'The recipe [:input] does not exist. Please choose a valid recipe.', ]); if ($this->localPathEqualsRemotePath()) { @@ -72,8 +124,9 @@ protected function validate(): void protected function localPathEqualsRemotePath(): bool { - return PathGenerator::localPath($this->recipe()[0]) - === PathGenerator::remotePath($this->remote(), $this->recipe()[0]); + return collect($this->recipePaths())->contains( + fn ($path) => PathGenerator::localPath($path) === PathGenerator::remotePath($this->remote(), $path) + ); } protected function remoteIsReadOnly(): bool @@ -83,7 +136,7 @@ protected function remoteIsReadOnly(): bool protected function commands(): Collection { - return collect($this->recipe()) + return collect($this->recipePaths()) ->map(fn ($path) => new SyncCommand( path: $path, operation: $this->operation(), @@ -102,9 +155,20 @@ protected function remote(): array return Arr::get($this->remotes(), $this->argument('remote')); } - protected function recipe(): array + protected function selectedRecipes(): array { - return Arr::get($this->recipes(), $this->argument('recipe')); + return (array) $this->argument('recipe'); + } + + protected function recipePaths(): array + { + $recipes = $this->recipes(); + + return collect($this->selectedRecipes()) + ->flatMap(fn ($name) => Arr::get($recipes, $name, [])) + ->unique() + ->values() + ->all(); } protected function remotes(): array diff --git a/src/Commands/Sync.php b/src/Commands/Sync.php index 45f9b66..555ef25 100644 --- a/src/Commands/Sync.php +++ b/src/Commands/Sync.php @@ -48,18 +48,26 @@ public function handle(): void }); }); + $recipes = $this->recipeNames(); + $label = count($this->selectedRecipes()) === 1 ? 'recipe' : 'recipes'; + $this->option('dry') - ? $this->info("The dry run of the {$this->argument('recipe')} recipe was successfull.") - : $this->info("The sync of the {$this->argument('recipe')} recipe was successfull."); + ? $this->info("The dry run of the {$recipes} {$label} was successful.") + : $this->info("The sync of the {$recipes} {$label} was successful."); } protected function confirmText(): string { $operation = $this->argument('operation'); - $recipe = $this->argument('recipe'); + $recipes = $this->recipeNames(); $remote = $this->argument('remote'); $preposition = $operation === 'pull' ? 'from' : 'to'; - return "You are about to $operation the $recipe $preposition $remote. Are you sure?"; + return "You are about to $operation the $recipes $preposition $remote. Are you sure?"; + } + + protected function recipeNames(): string + { + return collect($this->selectedRecipes())->join(', ', ' and '); } } diff --git a/tests/ExampleTest.php b/tests/ExampleTest.php deleted file mode 100644 index baa38e0..0000000 --- a/tests/ExampleTest.php +++ /dev/null @@ -1,14 +0,0 @@ -assertTrue(true); - } -} diff --git a/tests/Feature/RsyncOptionsTest.php b/tests/Feature/RsyncOptionsTest.php new file mode 100644 index 0000000..5ba2124 --- /dev/null +++ b/tests/Feature/RsyncOptionsTest.php @@ -0,0 +1,62 @@ + Http::response(['ip' => '1.2.3.4'])]); + + config()->set('sync.remotes', [ + 'production' => [ + 'user' => 'forge', + 'host' => '104.26.3.113', + 'port' => 1431, + 'root' => '/home/forge/site.com', + 'read_only' => false, + ], + ]); + + config()->set('sync.recipes', [ + 'assets' => ['storage/app/assets/'], + ]); +}); + +it('uses default config options when no CLI options are provided', function () { + config()->set('sync.options', ['--archive']); + + $this->artisan('sync:commands', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => ['assets'], + ])->expectsOutputToContain('--archive'); +}); + +it('uses CLI options when provided', function () { + $this->artisan('sync:commands', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => ['assets'], + '--option' => ['--compress', '--verbose'], + ])->expectsOutputToContain('--compress --verbose'); +}); + +it('adds dry-run flags when the dry option is set', function () { + $this->artisan('sync:commands', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => ['assets'], + '--dry' => true, + ])->expectsOutputToContain('--dry-run'); +}); + +it('deduplicates options', function () { + $localPath = base_path('storage/app/assets/'); + + $this->artisan('sync:commands', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => ['assets'], + '--option' => ['--archive', '--archive'], + ])->expectsOutputToContain("--archive {$localPath}"); +}); diff --git a/tests/Feature/SyncCommandsTest.php b/tests/Feature/SyncCommandsTest.php new file mode 100644 index 0000000..c413659 --- /dev/null +++ b/tests/Feature/SyncCommandsTest.php @@ -0,0 +1,53 @@ + Http::response(['ip' => '1.2.3.4'])]); + + config()->set('sync.remotes', [ + 'production' => [ + 'user' => 'forge', + 'host' => '104.26.3.113', + 'port' => 1431, + 'root' => '/home/forge/site.com', + 'read_only' => false, + ], + ]); + + config()->set('sync.recipes', [ + 'assets' => ['storage/app/assets/', 'storage/app/img/'], + ]); +}); + +it('outputs one rsync command per recipe path', function () { + $localAssets = base_path('storage/app/assets/'); + $localImg = base_path('storage/app/img/'); + + $this->artisan('sync:commands', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => ['assets'], + ]) + ->expectsOutputToContain("rsync -e 'ssh -p 1431' --archive {$localAssets} forge@104.26.3.113:/home/forge/site.com/storage/app/assets/") + ->expectsOutputToContain("rsync -e 'ssh -p 1431' --archive {$localImg} forge@104.26.3.113:/home/forge/site.com/storage/app/img/") + ->assertSuccessful(); +}); + +it('outputs the correct rsync command format for pull', function () { + $localAssets = base_path('storage/app/assets/'); + + config()->set('sync.recipes', [ + 'assets' => ['storage/app/assets/'], + ]); + + $this->artisan('sync:commands', [ + 'operation' => 'pull', + 'remote' => 'production', + 'recipe' => ['assets'], + ]) + ->expectsOutputToContain("rsync -e 'ssh -p 1431' --archive forge@104.26.3.113:/home/forge/site.com/storage/app/assets/ {$localAssets}") + ->assertSuccessful(); +}); diff --git a/tests/Feature/SyncListTest.php b/tests/Feature/SyncListTest.php new file mode 100644 index 0000000..63d4fdc --- /dev/null +++ b/tests/Feature/SyncListTest.php @@ -0,0 +1,41 @@ + Http::response(['ip' => '1.2.3.4'])]); + Prompt::fallbackWhen(true); + + config()->set('sync.remotes', [ + 'production' => [ + 'user' => 'forge', + 'host' => '104.26.3.113', + 'port' => 1431, + 'root' => '/home/forge/site.com', + 'read_only' => false, + ], + ]); + + config()->set('sync.recipes', [ + 'assets' => ['storage/app/assets/'], + ]); +}); + +it('executes successfully for a push operation', function () { + $this->artisan('sync:list', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => ['assets'], + ])->assertSuccessful(); +}); + +it('executes successfully for a pull operation', function () { + $this->artisan('sync:list', [ + 'operation' => 'pull', + 'remote' => 'production', + 'recipe' => ['assets'], + ])->assertSuccessful(); +}); diff --git a/tests/Feature/SyncTest.php b/tests/Feature/SyncTest.php new file mode 100644 index 0000000..133f189 --- /dev/null +++ b/tests/Feature/SyncTest.php @@ -0,0 +1,171 @@ + Http::response(['ip' => '1.2.3.4'])]); + Process::fake(); + + config()->set('sync.remotes', [ + 'production' => [ + 'user' => 'forge', + 'host' => '104.26.3.113', + 'port' => 1431, + 'root' => '/home/forge/site.com', + 'read_only' => false, + ], + ]); + + config()->set('sync.recipes', [ + 'assets' => ['storage/app/assets/', 'storage/app/img/'], + ]); +}); + +it('runs rsync for each path in the recipe during dry run', function () { + $this->withoutMockingConsoleOutput() + ->artisan('sync push production assets --dry'); + + $localAssets = base_path('storage/app/assets/'); + $localImg = base_path('storage/app/img/'); + + Process::assertRan(fn ($process) => str_contains($process->command, $localAssets)); + Process::assertRan(fn ($process) => str_contains($process->command, $localImg)); +}); + +it('skips confirmation during dry run', function () { + $this->withoutMockingConsoleOutput() + ->artisan('sync push production assets --dry'); + + Process::assertRan(fn ($process) => str_contains($process->command, 'rsync'), 2); +}); + +it('displays dry run messages', function () { + $this->artisan('sync', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => ['assets'], + '--dry' => true, + ])->expectsOutputToContain('Starting a dry run'); +}); + +it('displays dry run success message', function () { + $this->artisan('sync', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => ['assets'], + '--dry' => true, + ])->expectsOutputToContain('The dry run of the'); +}); + +it('aborts when user declines confirmation', function () { + $this->artisan('sync', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => ['assets'], + ])->expectsConfirmation( + 'You are about to push the assets to production. Are you sure?', + 'no', + )->assertSuccessful(); + + Process::assertNotRan(fn ($process) => str_contains($process->command, 'rsync')); +}); + +it('runs sync when user confirms', function () { + $this->artisan('sync', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => ['assets'], + ])->expectsConfirmation( + 'You are about to push the assets to production. Are you sure?', + 'yes', + )->expectsOutputToContain('Syncing files') + ->expectsOutputToContain('The sync of the'); + + Process::assertRan(fn ($process) => str_contains($process->command, 'rsync')); +}); + +it('uses the correct confirmation text for pull', function () { + $this->artisan('sync', [ + 'operation' => 'pull', + 'remote' => 'production', + 'recipe' => ['assets'], + ])->expectsConfirmation( + 'You are about to pull the assets from production. Are you sure?', + 'yes', + )->assertSuccessful(); +}); + +it('shows process output in verbose mode without dry run', function () { + $fakeResult = new FakeProcessResult(exitCode: 0, output: 'rsync output'); + $fakePending = Mockery::mock(PendingProcess::class); + $fakePending->shouldReceive('forever')->andReturnSelf(); + $fakePending->shouldReceive('run')->andReturnUsing(function ($command, $callback) use ($fakeResult) { + if ($callback) { + $callback('out', 'rsync output'); + } + + return $fakeResult; + }); + + Process::swap($fakePending); + + $this->artisan('sync', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => ['assets'], + '-v' => true, + ])->expectsConfirmation( + 'You are about to push the assets to production. Are you sure?', + 'yes', + )->expectsOutputToContain('Syncing files') + ->assertSuccessful(); +}); + +it('prompts for missing arguments interactively', function () { + $this->artisan('sync', ['--dry' => true]) + ->expectsChoice('Choose if you want to push or pull', 'push', ['push', 'pull']) + ->expectsChoice('Choose the remote you want to sync with', 'production', ['production']) + ->expectsChoice('Choose the recipes defining the paths to sync', ['assets'], ['assets']) + ->expectsChoice('Choose rsync options', ['--archive'], array_merge(array_keys(BaseCommand::RSYNC_OPTIONS), array_values(BaseCommand::RSYNC_OPTIONS))) + ->assertSuccessful(); + + Process::assertRan(fn ($process) => str_contains($process->command, 'rsync')); +}); + +it('runs rsync for paths from multiple recipes', function () { + config()->set('sync.recipes', [ + 'assets' => ['storage/app/assets/'], + 'uploads' => ['storage/app/uploads/'], + ]); + + $this->withoutMockingConsoleOutput() + ->artisan('sync push production assets uploads --dry'); + + $localAssets = base_path('storage/app/assets/'); + $localUploads = base_path('storage/app/uploads/'); + + Process::assertRan(fn ($process) => str_contains($process->command, $localAssets)); + Process::assertRan(fn ($process) => str_contains($process->command, $localUploads)); +}); + +it('uses the correct confirmation text for multiple recipes', function () { + config()->set('sync.recipes', [ + 'assets' => ['storage/app/assets/'], + 'uploads' => ['storage/app/uploads/'], + ]); + + $this->artisan('sync', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => ['assets', 'uploads'], + ])->expectsConfirmation( + 'You are about to push the assets and uploads to production. Are you sure?', + 'yes', + )->assertSuccessful(); +}); diff --git a/tests/Feature/ValidationTest.php b/tests/Feature/ValidationTest.php new file mode 100644 index 0000000..4eb5aa2 --- /dev/null +++ b/tests/Feature/ValidationTest.php @@ -0,0 +1,121 @@ +set('sync.remotes', [ + 'production' => [ + 'user' => 'forge', + 'host' => '104.26.3.113', + 'port' => 1431, + 'root' => '/home/forge/site.com', + 'read_only' => false, + ], + 'staging' => [ + 'user' => 'forge', + 'host' => '104.26.3.114', + 'root' => '/home/forge/staging.com', + 'read_only' => true, + ], + ]); + + config()->set('sync.recipes', [ + 'assets' => ['storage/app/assets/', 'storage/app/img/'], + ]); +}); + +it('validates that operation must be push or pull', function () { + Http::fake(['api.ipify.org/*' => Http::response(['ip' => '1.2.3.4'])]); + + $this->artisan('sync:commands', [ + 'operation' => 'invalid', + 'remote' => 'production', + 'recipe' => ['assets'], + ]); +})->throws(ValidationException::class); + +it('validates that remote must exist in config', function () { + Http::fake(['api.ipify.org/*' => Http::response(['ip' => '1.2.3.4'])]); + + $this->artisan('sync:commands', [ + 'operation' => 'push', + 'remote' => 'nonexistent', + 'recipe' => ['assets'], + ]); +})->throws(ValidationException::class); + +it('validates that recipe must exist in config', function () { + Http::fake(['api.ipify.org/*' => Http::response(['ip' => '1.2.3.4'])]); + + $this->artisan('sync:commands', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => ['nonexistent'], + ]); +})->throws(ValidationException::class); + +it('validates that all recipes must exist in config', function () { + Http::fake(['api.ipify.org/*' => Http::response(['ip' => '1.2.3.4'])]); + + $this->artisan('sync:commands', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => ['assets', 'nonexistent'], + ]); +})->throws(ValidationException::class); + +it('throws when pushing to a read-only remote', function () { + Http::fake(['api.ipify.org/*' => Http::response(['ip' => '1.2.3.4'])]); + + $this->artisan('sync:commands', [ + 'operation' => 'push', + 'remote' => 'staging', + 'recipe' => ['assets'], + ]); +})->throws(RuntimeException::class, "You can't push to the selected target as it is configured to be read-only."); + +it('allows pulling from a read-only remote', function () { + Http::fake(['api.ipify.org/*' => Http::response(['ip' => '1.2.3.4'])]); + + $this->artisan('sync:commands', [ + 'operation' => 'pull', + 'remote' => 'staging', + 'recipe' => ['assets'], + ])->assertSuccessful(); +}); + +it('throws when local path equals remote path', function () { + Http::fake(['api.ipify.org/*' => Http::response(['ip' => '104.26.3.113'])]); + + config()->set('sync.remotes.production.root', base_path()); + + $this->artisan('sync:commands', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => ['assets'], + ]); +})->throws(RuntimeException::class, 'The origin and target path are one and the same.'); + +it('throws when no remotes are configured', function () { + config()->set('sync.remotes', []); + + $this->artisan('sync:commands', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => ['assets'], + ]); +})->throws(RuntimeException::class, 'You need to define at least one remote in your config file.'); + +it('throws when no recipes are configured', function () { + config()->set('sync.recipes', []); + + $this->artisan('sync:commands', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => ['assets'], + ]); +})->throws(RuntimeException::class, 'You need to define at least one recipe in your config file.'); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..79d6220 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,12 @@ +extend(TestCase::class) + ->beforeEach(function () { + $this->app->register(SyncServiceProvider::class); + Once::flush(); + }) + ->in(__DIR__); diff --git a/tests/Unit/PathGeneratorTest.php b/tests/Unit/PathGeneratorTest.php new file mode 100644 index 0000000..a4a71a9 --- /dev/null +++ b/tests/Unit/PathGeneratorTest.php @@ -0,0 +1,53 @@ +toBe(base_path('storage/app/assets/')); +}); + +it('generates the remote path with user and host prefix', function () { + Http::fake(['api.ipify.org/*' => Http::response(['ip' => '1.2.3.4'])]); + + $remote = [ + 'user' => 'forge', + 'host' => '104.26.3.113', + 'root' => '/home/forge/site.com', + ]; + + expect(PathGenerator::remotePath($remote, 'storage/app/assets/')) + ->toBe('forge@104.26.3.113:/home/forge/site.com/storage/app/assets/'); +}); + +it('generates the remote path without prefix when remote host equals local host', function () { + Http::fake(['api.ipify.org/*' => Http::response(['ip' => '104.26.3.113'])]); + + $remote = [ + 'user' => 'forge', + 'host' => '104.26.3.113', + 'root' => '/home/forge/site.com', + ]; + + expect(PathGenerator::remotePath($remote, 'storage/app/assets/')) + ->toBe('/home/forge/site.com/storage/app/assets/'); +}); + +it('normalizes multiple slashes in the remote path', function () { + Http::fake(['api.ipify.org/*' => Http::response(['ip' => '1.2.3.4'])]); + + $remote = [ + 'user' => 'forge', + 'host' => '104.26.3.113', + 'root' => '/home/forge/site.com/', + ]; + + expect(PathGenerator::remotePath($remote, '/storage/app/assets/')) + ->toBe('forge@104.26.3.113:/home/forge/site.com/storage/app/assets/'); +}); diff --git a/tests/Unit/SyncCommandTest.php b/tests/Unit/SyncCommandTest.php new file mode 100644 index 0000000..4d81603 --- /dev/null +++ b/tests/Unit/SyncCommandTest.php @@ -0,0 +1,115 @@ + Http::response(['ip' => '1.2.3.4'])]); + + $this->remote = [ + 'user' => 'forge', + 'host' => '104.26.3.113', + 'port' => 1431, + 'root' => '/home/forge/site.com', + ]; +}); + +it('generates the correct array for a push operation', function () { + $command = new SyncCommand( + path: 'storage/app/assets/', + operation: 'push', + remote: $this->remote, + options: '--archive', + ); + + expect($command->toArray())->toBe([ + 'origin' => base_path('storage/app/assets/'), + 'target' => 'forge@104.26.3.113:/home/forge/site.com/storage/app/assets/', + 'options' => '--archive', + 'port' => '1431', + ]); +}); + +it('generates the correct array for a pull operation', function () { + $command = new SyncCommand( + path: 'storage/app/assets/', + operation: 'pull', + remote: $this->remote, + options: '--archive', + ); + + expect($command->toArray())->toBe([ + 'origin' => 'forge@104.26.3.113:/home/forge/site.com/storage/app/assets/', + 'target' => base_path('storage/app/assets/'), + 'options' => '--archive', + 'port' => '1431', + ]); +}); + +it('generates the correct rsync string for push', function () { + $command = new SyncCommand( + path: 'storage/app/assets/', + operation: 'push', + remote: $this->remote, + options: '--archive', + ); + + $localPath = base_path('storage/app/assets/'); + + expect((string) $command) + ->toBe("rsync -e 'ssh -p 1431' --archive {$localPath} forge@104.26.3.113:/home/forge/site.com/storage/app/assets/"); +}); + +it('generates the correct rsync string for pull', function () { + $command = new SyncCommand( + path: 'storage/app/assets/', + operation: 'pull', + remote: $this->remote, + options: '--archive', + ); + + $localPath = base_path('storage/app/assets/'); + + expect((string) $command) + ->toBe("rsync -e 'ssh -p 1431' --archive forge@104.26.3.113:/home/forge/site.com/storage/app/assets/ {$localPath}"); +}); + +it('uses the default port 22 when port is not set', function () { + $remote = collect($this->remote)->except('port')->all(); + + $command = new SyncCommand( + path: 'storage/app/assets/', + operation: 'push', + remote: $remote, + options: '--archive', + ); + + expect($command->toArray()['port'])->toBe('22'); +}); + +it('uses the custom port from the remote', function () { + $command = new SyncCommand( + path: 'storage/app/assets/', + operation: 'push', + remote: $this->remote, + options: '--archive', + ); + + expect($command->toArray()['port'])->toBe('1431'); +}); + +it('implements Arrayable and Stringable', function () { + $command = new SyncCommand( + path: 'storage/app/assets/', + operation: 'push', + remote: $this->remote, + options: '--archive', + ); + + expect($command) + ->toBeInstanceOf(Arrayable::class) + ->toBeInstanceOf(Stringable::class); +});