toBeTrue() ->and(class_exists(ScheduledVolumeBackupExecution::class))->toBeTrue() ->and(class_exists(VolumeBackupJob::class))->toBeTrue() ->and(class_exists(VolumeBackups::class))->toBeTrue() ->and(method_exists(LocalPersistentVolume::class, 'scheduledBackups'))->toBeTrue() ->and(method_exists(LocalFileVolume::class, 'scheduledBackups'))->toBeTrue(); }); it('keeps the volume backup script inside the Livewire root element', function () { $view = file_get_contents(resource_path('views/livewire/project/shared/storages/volume-backups.blade.php')); expect(strrpos($view, '@endscript'))->toBeLessThan(strrpos($view, '')); }); it('targets named volumes and application directory mounts through one backup relation', function () { $team = Team::factory()->create(); [$application, $volume] = createVolumeBackupApplication($team); $directory = LocalFileVolume::unguarded(fn () => LocalFileVolume::withoutEvents(fn () => LocalFileVolume::create([ 'uuid' => new_public_id(), 'fs_path' => './uploads', 'mount_path' => '/app/uploads', 'is_directory' => true, 'is_based_on_git' => false, 'is_preview_suffix_enabled' => true, 'resource_id' => $application->id, 'resource_type' => $application->getMorphClass(), ]))); $volumeBackup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', ]); $directoryBackup = $directory->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'weekly', ]); expect($volumeBackup->backupable->is($volume))->toBeTrue() ->and($volumeBackup->targetType())->toBe('Volume') ->and($volumeBackup->targetName())->toBe('app-data') ->and($volumeBackup->sourcePath())->toBe('app-data') ->and($directoryBackup->backupable->is($directory))->toBeTrue() ->and($directoryBackup->targetType())->toBe('Directory') ->and($directoryBackup->targetName())->toBe('./uploads') ->and($directoryBackup->sourcePath())->toBe($application->workdir().'/uploads') ->and($directoryBackup->server()?->id)->toBe($application->destination->server->id); }); it('provides application backup index and detail routes', function () { expect(Route::has('project.application.backup.index'))->toBeTrue() ->and(Route::has('project.application.backup.show'))->toBeTrue() ->and(Route::has('download.volume-backup'))->toBeTrue(); }); it('creates a scheduled backup with a preselected volume from the shared modal', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); $component = Livewire::test(CreateScheduledVolumeBackup::class, [ 'application' => $application, 'selectedTargetKey' => 'volume:'.$volume->id, ]) ->assertSet('targetKey', 'volume:'.$volume->id) ->assertSee($volume->name) ->assertDontSee('Save to S3') ->set('frequency', 'daily') ->call('submit') ->assertDispatched('success'); $backup = ScheduledVolumeBackup::query()->sole(); $component->assertRedirectToRoute('project.application.backup.show', [ 'project_uuid' => $application->project()->uuid, 'environment_uuid' => $application->environment->uuid, 'application_uuid' => $application->uuid, 'backup_uuid' => $backup->uuid, ]); expect($backup->backupable->is($volume))->toBeTrue() ->and($backup->frequency)->toBe('daily') ->and($backup->enabled)->toBeTrue() ->and($backup->save_s3)->toBeFalse() ->and($backup->s3_storage_id)->toBeNull(); }); it('handles scheduled backup persistence failures', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); $shouldFail = true; ScheduledVolumeBackup::creating(function () use (&$shouldFail): void { if ($shouldFail) { $shouldFail = false; throw new RuntimeException('Scheduled backup persistence failed.'); } }); Livewire::test(CreateScheduledVolumeBackup::class, [ 'application' => $application, 'selectedTargetKey' => 'volume:'.$volume->id, ]) ->set('frequency', 'daily') ->call('submit') ->assertDispatched('error', 'Scheduled backup persistence failed.'); expect(ScheduledVolumeBackup::query()->count())->toBe(0); }); it('respects the instance wire navigate setting after creating a scheduled backup', function () { InstanceSettings::unguarded(fn () => InstanceSettings::create([ 'id' => 0, 'is_wire_navigate_enabled' => false, ])); $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); $component = Livewire::test(CreateScheduledVolumeBackup::class, [ 'application' => $application, 'selectedTargetKey' => 'volume:'.$volume->id, ]) ->set('frequency', 'daily') ->call('submit'); $backup = ScheduledVolumeBackup::query()->sole(); $component->assertRedirectToRoute('project.application.backup.show', [ 'project_uuid' => $application->project()->uuid, 'environment_uuid' => $application->environment->uuid, 'application_uuid' => $application->uuid, 'backup_uuid' => $backup->uuid, ]); expect($component->effects)->not->toHaveKey('redirectUsingNavigate'); }); it('creates a scheduled backup for a preselected application directory', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application] = createVolumeBackupApplication($team); $directory = createApplicationBackupDirectory($application); Livewire::test(CreateScheduledVolumeBackup::class, [ 'application' => $application, 'selectedTargetKey' => 'directory:'.$directory->id, ]) ->assertSet('targetKey', 'directory:'.$directory->id) ->assertSee('Directory: '.$directory->fs_path) ->set('frequency', 'daily') ->call('submit') ->assertDispatched('success'); expect(ScheduledVolumeBackup::query()->sole()->backupable->is($directory))->toBeTrue(); }); it('rejects files and directory mounts owned by another application as backup targets', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application] = createVolumeBackupApplication($team); $otherApplication = Application::factory()->create([ 'environment_id' => $application->environment_id, 'destination_id' => $application->destination_id, 'destination_type' => $application->destination_type, ]); $foreignDirectory = createApplicationBackupDirectory($otherApplication); $file = createApplicationBackupDirectory($application, './config.json'); $file->update(['is_directory' => false]); Livewire::test(CreateScheduledVolumeBackup::class, ['application' => $application]) ->set('targetKey', 'directory:'.$file->id) ->call('submit') ->assertHasErrors('targetKey') ->set('targetKey', 'directory:'.$foreignDirectory->id) ->call('submit') ->assertHasErrors('targetKey'); expect(ScheduledVolumeBackup::query()->count())->toBe(0); }); it('shows volume backups on the application backups pages', function () { config(['cache.default' => 'array', 'app.maintenance.driver' => 'file']); InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', ]); $parameters = [ 'project_uuid' => $application->project()->uuid, 'environment_uuid' => $application->environment->uuid, 'application_uuid' => $application->uuid, ]; $this->get(route('project.application.backup.index', $parameters)) ->assertOk() ->assertSee('Scheduled Backups') ->assertSee($volume->name); $this->get(route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid])) ->assertOk() ->assertSee('

Backups

', false) ->assertSee($volume->name); }); it('shows directory backups on the application backup index and detail pages', function () { config(['cache.default' => 'array', 'app.maintenance.driver' => 'file']); InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application] = createVolumeBackupApplication($team); $directory = createApplicationBackupDirectory($application); $backup = $directory->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', ]); $parameters = [ 'project_uuid' => $application->project()->uuid, 'environment_uuid' => $application->environment->uuid, 'application_uuid' => $application->uuid, ]; $this->get(route('project.application.backup.index', $parameters)) ->assertOk() ->assertSee('Directory: '.$directory->fs_path); $this->get(route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid])) ->assertOk() ->assertSee('

Backups

', false) ->assertSee($directory->fs_path); }); it('splits scheduled backup settings and executions across dedicated urls', function () { config(['cache.default' => 'array', 'app.maintenance.driver' => 'file']); InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', 'save_s3' => true, ]); ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 'status' => 'success', 'filename' => '/data/coolify/backups/volumes/test/archive.tar.gz', 'finished_at' => now(), ]); $parameters = [ 'project_uuid' => $application->project()->uuid, 'environment_uuid' => $application->environment->uuid, 'application_uuid' => $application->uuid, 'backup_uuid' => $backup->uuid, ]; $generalUrl = route('project.application.backup.show', $parameters); $this->get($generalUrl) ->assertOk() ->assertSee('General') ->assertSee('S3') ->assertSee('Retention') ->assertSee('Executions') ->assertSee('Danger Zone') ->assertSee('Stop containers while creating the archive') ->assertDontSee('S3 Enabled') ->assertDontSee('Number of backups to keep') ->assertDontSee('Backup Availability:') ->assertDontSee('Delete Backups and Schedule'); $this->get($generalUrl.'/s3') ->assertOk() ->assertSeeText('No validated S3 available. Configure one here.') ->assertDontSee('Disable Local Backup') ->assertDontSee('Enable S3') ->assertDontSee('Disable S3') ->assertDontSee('S3 Storage Retention') ->assertDontSee('Local Backup Retention') ->assertDontSee('Frequency') ->assertDontSee('Backup Availability:'); $s3View = file_get_contents(resource_path('views/livewire/project/shared/storages/volume-backups/s3.blade.php')); expect(strpos($s3View, 'S3 Storage')) ->toBeLessThan(strpos($s3View, 'label="Disable Local Backup"')); $this->get($generalUrl.'/retention') ->assertOk() ->assertSee('Local Backup Retention') ->assertSee('S3 Storage Retention') ->assertSee('Number of backups to keep') ->assertDontSee('Stop containers while creating the archive') ->assertDontSee('Backup Availability:'); $this->get($generalUrl.'/executions') ->assertOk() ->assertSee('Backup Availability:') ->assertDontSee('Stop containers while creating the archive') ->assertDontSee('Number of backups to keep'); $this->get($generalUrl.'/danger') ->assertOk() ->assertSee('Danger Zone') ->assertSee('Delete Scheduled Backup') ->assertSee('Delete Backups and Schedule') ->assertDontSee('Stop containers while creating the archive') ->assertDontSee('Number of backups to keep') ->assertDontSee('Backup Availability:'); }); it('shows the configure backup modal trigger inside the volume card instead of inline backup settings', function () { InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); $component = Livewire::test(Show::class, [ 'storage' => $volume, 'resource' => $application, ]) ->set('isReadOnly', true) ->assertSee('Configure Backup') ->assertDontSee('Backups made while the application is writing'); $html = $component->html(); expect(strpos($html, 'Configure Backup')) ->toBeGreaterThan(strpos($html, 'toBeLessThan(strpos($html, '')); }); it('only shows the backup enabled badge for an enabled volume backup', function () { InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', 'enabled' => false, ]); $component = Livewire::test(Show::class, [ 'storage' => $volume, 'resource' => $application, ])->assertDontSee('Backup enabled'); $backup->update(['enabled' => true]); $backupUrl = route('project.application.backup.show', [ 'project_uuid' => $application->project()->uuid, 'environment_uuid' => $application->environment->uuid, 'application_uuid' => $application->uuid, 'backup_uuid' => $backup->uuid, ]); $component ->dispatch('refreshVolumeBackups') ->assertSeeInOrder(['Volume Name', 'Backup enabled']) ->assertSee('href="'.$backupUrl.'"', false); Livewire::test(Show::class, [ 'storage' => $volume, 'resource' => $application, 'isFirst' => false, ])->assertSeeInOrder(['Volume Name', 'Backup enabled']); }); it('links the backup enabled badge to a filtered backup list when the application has multiple schedules', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', 'enabled' => true, ]); createApplicationBackupDirectory($application)->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'weekly', 'enabled' => true, ]); $backupUrl = route('project.application.backup.index', [ 'project_uuid' => $application->project()->uuid, 'environment_uuid' => $application->environment->uuid, 'application_uuid' => $application->uuid, 'search' => $volume->name, ]); Livewire::test(Show::class, [ 'storage' => $volume, 'resource' => $application, ]) ->assertSee('Backup enabled') ->assertSee('href="'.$backupUrl.'"', false); }); it('offers backup configuration and status on application directory mounts', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application] = createVolumeBackupApplication($team); $directory = createApplicationBackupDirectory($application); $component = Livewire::test(FileStorage::class, ['fileStorage' => $directory]) ->assertSee('Configure Backup') ->assertSeeInOrder(['Convert to file', 'Configure Backup', 'Delete']) ->assertDontSee('Backup enabled'); $backup = $directory->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', 'enabled' => true, ]); $backupUrl = route('project.application.backup.show', [ 'project_uuid' => $application->project()->uuid, 'environment_uuid' => $application->environment->uuid, 'application_uuid' => $application->uuid, 'backup_uuid' => $backup->uuid, ]); $component ->dispatch('refreshVolumeBackups') ->assertSee('Backup enabled') ->assertSee('href="'.$backupUrl.'"', false); }); it('links the backup enabled badge to service database backups for database directory mounts', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application] = createVolumeBackupApplication($team); $service = Service::factory()->create([ 'environment_id' => $application->environment_id, 'destination_id' => $application->destination_id, 'destination_type' => $application->destination_type, ]); $database = ServiceDatabase::create([ 'uuid' => new_public_id(), 'name' => 'postgres', 'image' => 'postgres:17-alpine', 'service_id' => $service->id, ]); $directory = LocalFileVolume::unguarded(fn () => LocalFileVolume::withoutEvents(fn () => LocalFileVolume::create([ 'uuid' => new_public_id(), 'fs_path' => './postgres-data', 'mount_path' => '/var/lib/postgresql/data', 'is_directory' => true, 'is_based_on_git' => false, 'is_preview_suffix_enabled' => true, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), ]))); $directory->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', 'enabled' => true, ]); $backupUrl = route('project.service.database.backups', [ 'project_uuid' => $application->project()->uuid, 'environment_uuid' => $application->environment->uuid, 'service_uuid' => $service->uuid, 'stack_service_uuid' => $database->uuid, ]); Livewire::test(FileStorage::class, ['fileStorage' => $directory]) ->assertSee('Backup enabled') ->assertSee('href="'.$backupUrl.'"', false); }); it('prevents a backed up directory from being converted or deleted', function () { Process::fake(); $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application] = createVolumeBackupApplication($team); $directory = createApplicationBackupDirectory($application); $directory->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', ]); Livewire::test(FileStorage::class, ['fileStorage' => $directory]) ->call('convertToFile') ->assertDispatched('error') ->call('delete', 'password') ->assertDispatched('error'); expect($directory->fresh())->not->toBeNull() ->and($directory->fresh()->is_directory)->toBeTrue(); }); it('stores volume backup schedules and executions', function () { expect(Schema::hasColumns('scheduled_volume_backups', [ 'uuid', 'backupable_type', 'backupable_id', 'team_id', 's3_storage_id', 'frequency', 'enabled', 'save_s3', 'disable_local_backup', 'stop_during_backup', 'retention_amount_locally', 'retention_days_locally', 'retention_max_storage_locally', 'retention_amount_s3', 'retention_days_s3', 'retention_max_storage_s3', 'timeout', ]))->toBeTrue() ->and(Schema::hasColumns('scheduled_volume_backup_executions', [ 'uuid', 'scheduled_volume_backup_id', 'status', 'message', 'size', 'filename', 'stop_container_ids', 'stop_recovery_pending', 's3_cleanup_pending', 'finished_at', 'local_storage_deleted', 's3_storage_deleted', 's3_uploaded', ]))->toBeTrue(); }); it('records the S3 storage used by each volume backup execution', function () { expect(Schema::hasColumn('scheduled_volume_backup_executions', 's3_storage_id'))->toBeTrue() ->and((new ScheduledVolumeBackupExecution)->s3())->not->toBeNull(); }); it('exposes actions to manage and run volume backups', function () { expect(method_exists(VolumeBackups::class, 'save'))->toBeTrue() ->and(method_exists(VolumeBackups::class, 'backupNow'))->toBeTrue() ->and(method_exists(VolumeBackups::class, 'toggleEnabled'))->toBeTrue() ->and(method_exists(VolumeBackups::class, 'delete'))->toBeTrue() ->and(method_exists(VolumeBackups::class, 'cleanupFailed'))->toBeTrue() ->and(method_exists(VolumeBackups::class, 'cleanupDeleted'))->toBeTrue() ->and(method_exists(VolumeBackups::class, 'deleteBackup'))->toBeTrue(); }); it('declares the volume backup action return types', function () { $backupNowReturnType = (new ReflectionMethod(VolumeBackups::class, 'backupNow'))->getReturnType(); $deleteReturnType = (new ReflectionMethod(VolumeBackups::class, 'delete'))->getReturnType(); expect($backupNowReturnType)->toBeInstanceOf(ReflectionUnionType::class) ->and(collect($backupNowReturnType->getTypes())->map->getName()->all())->toEqualCanonicalizing([ RedirectResponse::class, Redirector::class, 'null', ]) ->and($deleteReturnType)->toBeInstanceOf(ReflectionUnionType::class) ->and(collect($deleteReturnType->getTypes())->map->getName()->all())->toEqualCanonicalizing(['bool', 'string']); }); it('renders volume backup executions like database backup executions', function () { InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume, $server] = createVolumeBackupApplication($team); $server->settings->update(['server_timezone' => 'Europe/Budapest']); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', ]); ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 'status' => 'success', 'filename' => '/data/coolify/backups/volumes/test/archive.tar.gz', 'size' => 1024, 'finished_at' => now(), ]); Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application, 'section' => 'executions']) ->assertSet('timezone', 'Europe/Budapest') ->assertSeeInOrder([ 'Executions', 'Success', 'Volume: app-data', 'Backup Availability:', ]) ->assertSee('Executions') ->assertSee('Page 1 of 1') ->assertSee('Cleanup Failed Backups') ->assertSee('Cleanup Deleted') ->assertSee('Backup Availability:') ->assertSee('Local Storage') ->assertSee('Location: /data/coolify/backups/volumes/test/archive.tar.gz') ->assertSee('Download') ->assertSee('Delete') ->assertDontSee('border border-neutral-200', false); }); it('cleans up failed and fully deleted volume backup execution records', function () { InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', ]); $failed = ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 'status' => 'failed', ]); $deleted = ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 'status' => 'success', 'local_storage_deleted' => true, 's3_storage_deleted' => true, 's3_uploaded' => true, ]); Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) ->call('cleanupFailed') ->assertDispatched('success') ->call('cleanupDeleted') ->assertDispatched('success'); expect($failed->fresh())->toBeNull() ->and($deleted->fresh())->toBeNull(); }); it('deletes an individual volume backup archive and execution', function () { InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); Process::fake(); $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', ]); $execution = ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 'status' => 'success', 'filename' => '/data/coolify/backups/volumes/test/archive.tar.gz', 'finished_at' => now(), ]); Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) ->call('deleteBackup', $execution->id, 'password') ->assertDispatched('success'); expect($execution->fresh())->toBeNull(); Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f') && str_contains($process->command, 'archive.tar.gz')); }); it('deletes an individual S3 archive from the storage recorded on its execution', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); $originalStorage = S3Storage::create([ 'name' => 'Original storage', 'region' => 'us-east-1', 'key' => 'original-key', 'secret' => 'secret', 'bucket' => 'original-bucket', 'endpoint' => 'https://s3.example.com', 'team_id' => $team->id, 'is_usable' => true, ]); $newStorage = S3Storage::create([ 'name' => 'New storage', 'region' => 'us-east-1', 'key' => 'new-key', 'secret' => 'secret', 'bucket' => 'new-bucket', 'endpoint' => 'https://s3.example.com', 'team_id' => $team->id, 'is_usable' => true, ]); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 's3_storage_id' => $newStorage->id, 'frequency' => 'daily', 'save_s3' => true, ]); $execution = ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 's3_storage_id' => $originalStorage->id, 'status' => 'success', 'filename' => '/data/coolify/backups/volumes/test/historical.tar.gz', 'local_storage_deleted' => true, 's3_uploaded' => true, ]); $disk = Mockery::mock(); $disk->shouldReceive('delete') ->once() ->with(['/data/coolify/backups/volumes/test/historical.tar.gz']) ->andReturnTrue(); Storage::shouldReceive('build') ->once() ->with(Mockery::on(fn (array $config): bool => $config['key'] === 'original-key')) ->andReturn($disk); Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) ->set('delete_backup_s3', true) ->call('deleteBackup', $execution->id, 'password') ->assertDispatched('success'); expect($execution->fresh())->toBeNull(); }); it('prevents another team from downloading a volume backup', function () { config(['app.maintenance.driver' => 'file']); $backupTeam = Team::factory()->create(); [$application, $volume] = createVolumeBackupApplication($backupTeam); $backup = $volume->scheduledBackups()->create([ 'team_id' => $backupTeam->id, 'frequency' => 'daily', ]); $execution = ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 'status' => 'success', 'filename' => '/data/coolify/backups/volumes/test/archive.tar.gz', ]); $otherTeam = Team::factory()->create(); signInForVolumeBackups($this, $otherTeam); $this->get(route('download.volume-backup', $execution->id))->assertForbidden(); }); it('enables and disables volume backups from the title action', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); $component = Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) ->assertSet('enabled', false) ->assertSee('Enable Backup') ->call('toggleEnabled') ->assertSet('enabled', true) ->assertSee('Disable Backup'); expect(ScheduledVolumeBackup::query()->sole()->enabled)->toBeTrue(); $component->call('toggleEnabled')->assertSet('enabled', false); expect(ScheduledVolumeBackup::query()->sole()->enabled)->toBeFalse(); }); it('enables and disables volume S3 backups from the S3 title action', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); $s3Storage = S3Storage::create([ 'name' => 'Volume backups', 'region' => 'us-east-1', 'key' => 'key', 'secret' => 'secret', 'bucket' => 'bucket', 'endpoint' => 'https://s3.example.com', 'team_id' => $team->id, 'is_usable' => true, ]); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', 's3_storage_id' => $s3Storage->id, ]); $component = Livewire::test(VolumeBackups::class, [ 'storage' => $volume, 'resource' => $application, 'section' => 's3', ]) ->assertSee('Enable S3') ->call('toggleS3') ->assertSet('saveToS3', true) ->assertSee('Disable S3'); expect($backup->refresh()->save_s3)->toBeTrue(); $component->call('toggleS3')->assertSet('saveToS3', false); expect($backup->refresh()->save_s3)->toBeFalse(); }); it('shows and saves volume S3 retention while S3 backups are disabled', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', 'save_s3' => false, ]); Livewire::test(VolumeBackups::class, [ 'storage' => $volume, 'resource' => $application, 'section' => 'retention', ]) ->assertSee('S3 Storage Retention') ->set('retentionAmountS3', 12) ->set('retentionDaysS3', 30) ->set('retentionMaxStorageS3', 4.5) ->call('save') ->assertHasNoErrors(); expect($backup->refresh()->save_s3)->toBeFalse() ->and($backup->retention_amount_s3)->toBe(12) ->and($backup->retention_days_s3)->toBe(30) ->and($backup->retention_max_storage_s3)->toBe(4.5); }); it('only updates S3 fields when toggling volume S3 backups', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); $s3Storage = S3Storage::create([ 'name' => 'Volume backups', 'region' => 'us-east-1', 'key' => 'key', 'secret' => 'secret', 'bucket' => 'bucket', 'endpoint' => 'https://s3.example.com', 'team_id' => $team->id, 'is_usable' => true, ]); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', 's3_storage_id' => $s3Storage->id, ]); Livewire::test(VolumeBackups::class, [ 'storage' => $volume, 'resource' => $application, 'section' => 's3', ]) ->set('frequency', 'not a valid schedule') ->call('toggleS3') ->assertDispatched('success'); expect($backup->refresh()->save_s3)->toBeTrue() ->and($backup->frequency)->toBe('daily'); }); function createVolumeBackupApplication(Team $team): array { InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); $server = Server::factory()->create([ 'team_id' => $team->id, 'private_key_id' => $privateKey->id, 'ip' => '203.0.113.10', ]); $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); $project = Project::factory()->create(['team_id' => $team->id]); $environment = Environment::factory()->create(['project_id' => $project->id]); $application = Application::factory()->create([ 'environment_id' => $environment->id, 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), ]); $volume = LocalPersistentVolume::create([ 'name' => 'app-data', 'mount_path' => '/data', 'resource_id' => $application->id, 'resource_type' => $application->getMorphClass(), ]); return [$application, $volume, $server]; } function createApplicationBackupDirectory(Application $application, string $path = './uploads'): LocalFileVolume { return LocalFileVolume::unguarded(fn () => LocalFileVolume::withoutEvents(fn () => LocalFileVolume::create([ 'uuid' => new_public_id(), 'fs_path' => $path, 'mount_path' => '/app/uploads', 'is_directory' => true, 'is_based_on_git' => false, 'is_preview_suffix_enabled' => true, 'resource_id' => $application->id, 'resource_type' => $application->getMorphClass(), ]))); } function signInForVolumeBackups($testCase, Team $team): User { $user = User::factory()->create(); $user->teams()->attach($team, ['role' => 'owner']); $testCase->actingAs($user); session(['currentTeam' => $team]); return $user; } it('creates a local scheduled backup for a persistent volume', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) ->assertSee('General') ->assertSee('Volume:') ->assertSee('inconsistent or corrupted') ->assertSee('gracefully stop containers') ->set('frequency', 'daily') ->set('retentionAmountLocally', 5) ->set('retentionDaysLocally', 14) ->set('retentionMaxStorageLocally', 1.5) ->set('stopDuringBackup', true) ->call('save') ->assertDispatched('success'); $backup = ScheduledVolumeBackup::query()->sole(); expect($backup->backupable->is($volume))->toBeTrue() ->and($backup->team_id)->toBe($team->id) ->and($backup->frequency)->toBe('daily') ->and($backup->retention_amount_locally)->toBe(5) ->and($backup->retention_days_locally)->toBe(14) ->and($backup->retention_max_storage_locally)->toBe(1.5) ->and($backup->stop_during_backup)->toBeTrue() ->and($backup->save_s3)->toBeFalse(); }); it('only accepts a usable S3 storage owned by the current team', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); $foreignStorage = S3Storage::create([ 'name' => 'Foreign S3', 'region' => 'us-east-1', 'key' => 'key', 'secret' => 'secret', 'bucket' => 'bucket', 'endpoint' => 'https://s3.example.com', 'team_id' => Team::factory()->create()->id, 'is_usable' => true, ]); Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) ->set('frequency', 'daily') ->set('saveToS3', true) ->set('s3StorageId', $foreignStorage->id) ->call('save') ->assertHasErrors('s3StorageId'); expect(ScheduledVolumeBackup::query()->count())->toBe(0); }); it('saves the database-style S3 backup controls immediately', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); $s3Storage = S3Storage::create([ 'name' => 'Volume backups', 'region' => 'us-east-1', 'key' => 'key', 'secret' => 'secret', 'bucket' => 'bucket', 'endpoint' => 'https://s3.example.com', 'team_id' => $team->id, 'is_usable' => true, ]); $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', ]); $component = Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) ->assertSet('s3StorageId', $s3Storage->id) ->set('saveToS3', true) ->call('instantSave') ->set('disableLocalBackup', true) ->set('retentionAmountS3', 10) ->set('retentionDaysS3', 30) ->set('retentionMaxStorageS3', 5.5) ->call('instantSave'); $backup = ScheduledVolumeBackup::query()->sole(); expect($backup->save_s3)->toBeTrue() ->and($backup->s3_storage_id)->toBe($s3Storage->id) ->and($backup->disable_local_backup)->toBeTrue() ->and($backup->retention_amount_s3)->toBe(10) ->and($backup->retention_days_s3)->toBe(30) ->and($backup->retention_max_storage_s3)->toBe(5.5); $component->set('saveToS3', false)->call('instantSave'); expect($backup->fresh()->save_s3)->toBeFalse() ->and($backup->fresh()->s3_storage_id)->toBe($s3Storage->id) ->and($backup->fresh()->disable_local_backup)->toBeFalse(); }); it('saves the selected volume backup S3 storage immediately while S3 is disabled', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); $firstS3Storage = S3Storage::create([ 'name' => 'First storage', 'region' => 'us-east-1', 'key' => 'key', 'secret' => 'secret', 'bucket' => 'bucket', 'endpoint' => 'https://s3.example.com', 'team_id' => $team->id, 'is_usable' => true, ]); $secondS3Storage = S3Storage::create([ 'name' => 'Second storage', 'region' => 'us-east-1', 'key' => 'key', 'secret' => 'secret', 'bucket' => 'bucket', 'endpoint' => 'https://s3.example.com', 'team_id' => $team->id, 'is_usable' => true, ]); $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', 'save_s3' => false, 's3_storage_id' => $firstS3Storage->id, ]); Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application, 'section' => 's3']) ->set('s3StorageId', $secondS3Storage->id) ->assertDispatched('success'); $backup = ScheduledVolumeBackup::query()->sole(); expect($backup->save_s3)->toBeFalse() ->and($backup->s3_storage_id)->toBe($secondS3Storage->id); }); it('saves each editable volume backup checkbox immediately', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); $s3Storage = S3Storage::create([ 'name' => 'Volume backups', 'region' => 'us-east-1', 'key' => 'key', 'secret' => 'secret', 'bucket' => 'bucket', 'endpoint' => 'https://s3.example.com', 'team_id' => $team->id, 'is_usable' => true, ]); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 's3_storage_id' => $s3Storage->id, 'frequency' => 'daily', 'save_s3' => true, ]); $generalComponent = Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]); preg_match('/]*wire:model=(?:"stopDuringBackup"|stopDuringBackup))[^>]*>/', $generalComponent->html(), $matches); expect($matches[0] ?? null)->not->toBeNull() ->and($matches[0])->toContain("wire:click='instantSave'"); $s3Component = Livewire::test(VolumeBackups::class, [ 'storage' => $volume, 'resource' => $application, 'section' => 's3', ]); foreach (['disableLocalBackup'] as $property) { preg_match('/]*wire:model=(?:"'.$property.'"|'.$property.'))[^>]*>/', $s3Component->html(), $matches); expect($matches[0] ?? null)->not->toBeNull() ->and($matches[0])->toContain("wire:click='instantSave'"); } $generalComponent->set('stopDuringBackup', true)->call('instantSave')->assertDispatched('success'); expect($backup->refresh()->stop_during_backup)->toBeTrue(); $generalComponent->set('stopDuringBackup', false)->call('instantSave')->assertDispatched('success'); expect($backup->refresh()->stop_during_backup)->toBeFalse(); }); it('allows volume S3 backups to be disabled when no usable storage remains', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', 'save_s3' => true, 's3_storage_id' => null, ]); $component = Livewire::test(VolumeBackups::class, [ 'storage' => $volume, 'resource' => $application, 'section' => 's3', ]) ->assertSet('saveToS3', true) ->assertSeeHtml('

S3

') ->assertSeeText('No validated S3 available. Configure one here.') ->assertSeeHtml('href="'.route('storage.index').'"') ->assertSeeHtml('>here') ->assertDontSee('Save') ->assertDontSee('Disable S3') ->assertDontSee('S3 Storage') ->assertDontSee('Disable Local Backup') ->call('toggleS3') ->assertDispatched('success') ->assertSet('saveToS3', false) ->assertDontSee('Enable S3'); expect($backup->refresh()->save_s3)->toBeFalse() ->and($backup->s3_storage_id)->toBeNull(); }); it('disables S3 volume backups when the storage is deleted', function () { $team = Team::factory()->create(); [$application, $volume] = createVolumeBackupApplication($team); $s3Storage = S3Storage::create([ 'name' => 'Volume backups', 'region' => 'us-east-1', 'key' => 'key', 'secret' => 'secret', 'bucket' => 'bucket', 'endpoint' => 'https://s3.example.com', 'team_id' => $team->id, 'is_usable' => true, ]); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 's3_storage_id' => $s3Storage->id, 'frequency' => 'daily', 'save_s3' => true, ]); $execution = ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 's3_storage_id' => $s3Storage->id, 'status' => 'success', 'filename' => '/data/coolify/backups/volumes/test/s3.tar.gz', 's3_uploaded' => true, 's3_cleanup_pending' => true, ]); $s3Storage->delete(); expect($backup->fresh()->save_s3)->toBeFalse() ->and($backup->fresh()->s3_storage_id)->toBeNull() ->and($execution->fresh()->s3_storage_deleted)->toBeTrue() ->and($execution->fresh()->s3_cleanup_pending)->toBeFalse(); }); it('marks historical executions deleted when their recorded S3 storage is removed', function () { $team = Team::factory()->create(); [$application, $volume] = createVolumeBackupApplication($team); $originalStorage = S3Storage::create([ 'name' => 'Original storage', 'region' => 'us-east-1', 'key' => 'original-key', 'secret' => 'secret', 'bucket' => 'original-bucket', 'endpoint' => 'https://s3.example.com', 'team_id' => $team->id, ]); $newStorage = S3Storage::create([ 'name' => 'New storage', 'region' => 'us-east-1', 'key' => 'new-key', 'secret' => 'secret', 'bucket' => 'new-bucket', 'endpoint' => 'https://s3.example.com', 'team_id' => $team->id, ]); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 's3_storage_id' => $newStorage->id, 'frequency' => 'daily', 'save_s3' => true, ]); $execution = ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 's3_storage_id' => $originalStorage->id, 'status' => 'success', 'filename' => '/data/coolify/backups/volumes/test/historical.tar.gz', 's3_uploaded' => true, 's3_cleanup_pending' => true, ]); $originalStorage->delete(); expect($backup->fresh()->s3_storage_id)->toBe($newStorage->id) ->and($execution->fresh()->s3_storage_id)->toBeNull() ->and($execution->fresh()->s3_storage_deleted)->toBeTrue() ->and($execution->fresh()->s3_cleanup_pending)->toBeFalse(); }); it('queues a manual backup before a schedule has been saved', function () { Queue::fake(); $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); $component = Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) ->call('backupNow') ->assertDispatched('success'); $backup = ScheduledVolumeBackup::query()->sole(); $component->assertRedirectToRoute('project.application.backup.executions', [ 'project_uuid' => $application->project()->uuid, 'environment_uuid' => $application->environment->uuid, 'application_uuid' => $application->uuid, 'backup_uuid' => $backup->uuid, ]); expect($backup->enabled)->toBeFalse(); Queue::assertPushed(VolumeBackupJob::class, fn (VolumeBackupJob $job) => $job->backup->is($backup)); }); it('queues a manual backup when its S3 storage has become unusable', function () { Queue::fake(); $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); $s3Storage = S3Storage::create([ 'name' => 'Unavailable storage', 'region' => 'us-east-1', 'key' => 'key', 'secret' => 'secret', 'bucket' => 'bucket', 'endpoint' => 'https://s3.example.com', 'team_id' => $team->id, 'is_usable' => false, ]); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 's3_storage_id' => $s3Storage->id, 'frequency' => 'daily', 'save_s3' => true, ]); $parameters = [ 'project_uuid' => $application->project()->uuid, 'environment_uuid' => $application->environment->uuid, 'application_uuid' => $application->uuid, 'backup_uuid' => $backup->uuid, ]; Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) ->call('backupNow') ->assertRedirectToRoute('project.application.backup.executions', $parameters); Queue::assertPushed(VolumeBackupJob::class, fn (VolumeBackupJob $job) => $job->backup->is($backup)); }); it('deletes local archives before deleting a volume backup schedule', function () { Process::fake(); $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', ]); ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 'status' => 'success', 'filename' => '/data/coolify/backups/volumes/test/archive.tar.gz', 'size' => 128, ]); Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) ->call('delete', 'password') ->assertDispatched('success') ->assertRedirectToRoute('project.application.backup.index', [ 'project_uuid' => $application->project()->uuid, 'environment_uuid' => $application->environment->uuid, 'application_uuid' => $application->uuid, ]); expect(ScheduledVolumeBackup::query()->count())->toBe(0); Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f') && str_contains($process->command, 'archive.tar.gz')); }); it('deletes a volume backup schedule without a password when two-step confirmation is disabled', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); InstanceSettings::get()->update(['disable_two_step_confirmation' => true]); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', ]); Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) ->call('delete', '') ->assertDispatched('success'); expect($backup->fresh())->toBeNull(); }); it('deletes S3 archives from the storage recorded on each execution', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); $originalStorage = S3Storage::create([ 'name' => 'Original storage', 'region' => 'us-east-1', 'key' => 'original-key', 'secret' => 'secret', 'bucket' => 'original-bucket', 'endpoint' => 'https://s3.example.com', 'team_id' => $team->id, 'is_usable' => true, ]); $newStorage = S3Storage::create([ 'name' => 'New storage', 'region' => 'us-east-1', 'key' => 'new-key', 'secret' => 'secret', 'bucket' => 'new-bucket', 'endpoint' => 'https://s3.example.com', 'team_id' => $team->id, 'is_usable' => true, ]); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 's3_storage_id' => $newStorage->id, 'frequency' => 'daily', 'save_s3' => true, ]); ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 's3_storage_id' => $originalStorage->id, 'status' => 'success', 'filename' => '/data/coolify/backups/volumes/test/historical.tar.gz', 'local_storage_deleted' => true, 's3_uploaded' => true, ]); $disk = Mockery::mock(); $disk->shouldReceive('delete') ->once() ->with(['/data/coolify/backups/volumes/test/historical.tar.gz']) ->andReturnTrue(); Storage::shouldReceive('build') ->once() ->with(Mockery::on(fn (array $config): bool => $config['key'] === 'original-key')) ->andReturn($disk); Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) ->call('delete', 'password') ->assertDispatched('success'); expect($backup->fresh())->toBeNull(); }); it('refuses to delete a schedule while its backup is running', function () { Process::fake(); $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', ]); ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 'status' => 'running', ]); Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) ->call('delete', 'password') ->assertDispatched('error'); expect($backup->fresh())->not->toBeNull(); Process::assertNothingRan(); }); it('uses the backup operation lock while deleting a schedule', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', ]); $lock = Cache::lock(VolumeBackupJob::lockKey($backup->id), 60); $lock->get(); try { Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) ->call('delete', 'password') ->assertDispatched('error'); expect($backup->fresh())->not->toBeNull(); } finally { $lock->release(); } }); it('prevents a volume with tracked backup archives from being deleted', function () { $team = Team::factory()->create(); [$application, $volume] = createVolumeBackupApplication($team); $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', ]); expect(fn () => $volume->delete())->toThrow(RuntimeException::class, 'Delete this volume backup schedule'); expect($volume->fresh())->not->toBeNull(); }); it('aborts storage deletion when scheduled backups exist', function () { $team = Team::factory()->create(); [$application, $volume] = createVolumeBackupApplication($team); $directory = createApplicationBackupDirectory($application); $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', ]); $directory->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', ]); expect(fn () => $volume->abortIfScheduledBackupsExist()) ->toThrow(HttpException::class, 'Delete this volume backup schedule and its archives before deleting the volume.') ->and(fn () => $directory->abortIfScheduledBackupsExist()) ->toThrow(HttpException::class, 'Delete this directory backup schedule and its archives before deleting the directory.'); }); it('deletes disabled volume backup archives before deleting their application', function () { Process::fake(); Queue::fake(); $team = Team::factory()->create(); [$application, $volume] = createVolumeBackupApplication($team); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', 'enabled' => false, ]); $execution = ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 'status' => 'success', 'filename' => '/data/coolify/backups/volumes/test/application-delete.tar.gz', ]); $application->delete(); (new DeleteResourceJob($application))->handle(); expect($backup->fresh())->toBeNull() ->and($execution->fresh())->toBeNull(); Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f') && str_contains($process->command, 'application-delete.tar.gz')); }); it('marks a running execution failed even when the job instance lost its execution state', function () { Process::fake(); $team = Team::factory()->create(); [$application, $volume] = createVolumeBackupApplication($team); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', ]); $execution = ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 'status' => 'running', 'filename' => '/data/coolify/backups/volumes/test/timed-out.tar.gz', ]); (new VolumeBackupJob($backup))->failed(new RuntimeException('Worker timed out')); expect($execution->fresh()->status)->toBe('failed') ->and($execution->fresh()->message)->toBe('Worker timed out') ->and($execution->fresh()->finished_at)->not->toBeNull() ->and($execution->fresh()->filename)->toBeNull(); Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f') && str_contains($process->command, 'timed-out.tar.gz')); }); it('archives a named volume on its server', function () { config(['broadcasting.default' => 'null']); InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); $team = Team::factory()->create(); [$application, $volume] = createVolumeBackupApplication($team); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', 'enabled' => true, 'retention_amount_locally' => 7, 'retention_amount_s3' => 7, 'timeout' => 3600, ]); Process::fake([ '*docker ps -q*' => 'abc123', '*du -b*' => '128', '*' => '', ]); $job = new VolumeBackupJob($backup); $middleware = $job->middleware(); $job->handle(); $execution = ScheduledVolumeBackupExecution::query()->sole(); expect($middleware)->toHaveCount(1) ->and($middleware[0])->toBeInstanceOf(WithoutOverlapping::class) ->and($job->queue)->toBe(crons_queue()) ->and($execution->status)->toBe('success') ->and($execution->size)->toBe(128) ->and($execution->filename)->toEndWith('.tar.gz'); Process::assertRan(fn ($process) => str_contains($process->command, 'docker volume inspect') && str_contains($process->command, 'docker run --rm --name ') && str_contains($process->command, 'app-data:/volume:ro') && str_contains($process->command, 'tar -czf -') && str_contains($process->command, '> ') && str_contains($process->command, '.tar.gz') && ! str_contains($process->command, ':/backup')); }); it('keeps the upload destination on the volume backup execution', function () { config(['broadcasting.default' => 'null']); InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); $team = Team::factory()->create(); [$application, $volume] = createVolumeBackupApplication($team); $s3Storage = S3Storage::create([ 'name' => 'Execution destination', 'region' => 'us-east-1', 'key' => 'key', 'secret' => 'secret', 'bucket' => 'bucket', 'endpoint' => 'https://s3.example.com', 'team_id' => $team->id, 'is_usable' => true, ]); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', 'save_s3' => true, 's3_storage_id' => $s3Storage->id, ]); $sshDisk = Storage::fake('ssh-keys'); $disk = Mockery::mock(); $disk->shouldReceive('files')->zeroOrMoreTimes()->andReturn([]); $disk->shouldReceive('delete')->zeroOrMoreTimes()->andReturnTrue(); Storage::shouldReceive('disk')->with('ssh-keys')->andReturn($sshDisk); Storage::shouldReceive('build')->zeroOrMoreTimes()->andReturn($disk); Process::fake([ '*du -b*' => '128', '*' => '', ]); (new VolumeBackupJob($backup))->handle(); $execution = ScheduledVolumeBackupExecution::query()->sole(); expect($execution->s3_storage_id)->toBe($s3Storage->id) ->and($execution->s3->is($s3Storage))->toBeTrue(); }); it('removes local volume backups older than the configured retention days', function () { config(['broadcasting.default' => 'null']); InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); $team = Team::factory()->create(); [$application, $volume] = createVolumeBackupApplication($team); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', 'retention_amount_locally' => 0, 'retention_days_locally' => 7, 'retention_max_storage_locally' => 0, ]); $expiredExecution = ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 'status' => 'success', 'filename' => '/data/coolify/backups/volumes/test/expired.tar.gz', 'size' => 64, ]); ScheduledVolumeBackupExecution::query()->whereKey($expiredExecution)->update(['created_at' => now()->subDays(8)]); $recentExecution = ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 'status' => 'success', 'filename' => '/data/coolify/backups/volumes/test/recent.tar.gz', 'size' => 64, ]); ScheduledVolumeBackupExecution::query()->whereKey($recentExecution)->update(['created_at' => now()->subDay()]); Process::fake([ '*du -b*' => '128', '*' => '', ]); (new VolumeBackupJob($backup))->handle(); expect($expiredExecution->fresh())->toBeNull() ->and($recentExecution->fresh())->not->toBeNull(); Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f') && str_contains($process->command, 'expired.tar.gz')); }); it('removes oldest local volume backups over the configured storage limit', function () { config(['broadcasting.default' => 'null']); InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); $team = Team::factory()->create(); [$application, $volume] = createVolumeBackupApplication($team); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', 'retention_amount_locally' => 0, 'retention_days_locally' => 0, 'retention_max_storage_locally' => 0.00000015, ]); $oldExecution = ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 'status' => 'success', 'filename' => '/data/coolify/backups/volumes/test/over-limit.tar.gz', 'size' => 64, ]); ScheduledVolumeBackupExecution::query()->whereKey($oldExecution)->update(['created_at' => now()->subDay()]); Process::fake([ '*du -b*' => '128', '*' => '', ]); (new VolumeBackupJob($backup))->handle(); expect($oldExecution->fresh())->toBeNull() ->and($backup->executions()->where('status', 'success')->count())->toBe(1); Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f') && str_contains($process->command, 'over-limit.tar.gz')); }); it('removes retained S3 archives from the storage recorded on each execution', function () { $team = Team::factory()->create(); [$application, $volume, $server] = createVolumeBackupApplication($team); $originalStorage = S3Storage::create([ 'name' => 'Original storage', 'region' => 'us-east-1', 'key' => 'original-key', 'secret' => 'secret', 'bucket' => 'original-bucket', 'endpoint' => 'https://s3.example.com', 'team_id' => $team->id, 'is_usable' => true, ]); $newStorage = S3Storage::create([ 'name' => 'New storage', 'region' => 'us-east-1', 'key' => 'new-key', 'secret' => 'secret', 'bucket' => 'new-bucket', 'endpoint' => 'https://s3.example.com', 'team_id' => $team->id, 'is_usable' => true, ]); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 's3_storage_id' => $newStorage->id, 'frequency' => 'daily', 'save_s3' => true, 'retention_amount_locally' => 0, 'retention_days_locally' => 0, 'retention_max_storage_locally' => 0, 'retention_amount_s3' => 1, 'retention_days_s3' => 0, 'retention_max_storage_s3' => 0, ]); $oldExecution = ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 's3_storage_id' => $originalStorage->id, 'status' => 'success', 'filename' => '/data/coolify/backups/volumes/test/old.tar.gz', 's3_uploaded' => true, 'created_at' => now()->subDay(), ]); ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 's3_storage_id' => $newStorage->id, 'status' => 'success', 'filename' => '/data/coolify/backups/volumes/test/new.tar.gz', 's3_uploaded' => true, ]); $disk = Mockery::mock(); $disk->shouldReceive('delete')->once()->with(['/data/coolify/backups/volumes/test/old.tar.gz'])->andReturnTrue(); Storage::shouldReceive('build') ->once() ->with(Mockery::on(fn (array $config): bool => $config['key'] === 'original-key')) ->andReturn($disk); $job = new VolumeBackupJob($backup); $method = (new ReflectionClass($job))->getMethod('removeExpiredBackups'); $method->invoke($job, $server); expect($oldExecution->fresh()->s3_storage_deleted)->toBeTrue(); }); it('does not query execution history when local retention is unlimited', function () { $team = Team::factory()->create(); [$application, $volume, $server] = createVolumeBackupApplication($team); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', 'save_s3' => false, 'retention_amount_locally' => 0, 'retention_days_locally' => 0, 'retention_max_storage_locally' => 0, ]); ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 'status' => 'success', 'filename' => '/data/coolify/backups/volumes/test/unlimited.tar.gz', ]); $job = new VolumeBackupJob($backup); $method = (new ReflectionClass($job))->getMethod('removeExpiredBackups'); DB::enableQueryLog(); DB::flushQueryLog(); $method->invoke($job, $server); $executionQueries = collect(DB::getQueryLog()) ->filter(fn (array $query): bool => str_contains($query['query'], 'scheduled_volume_backup_executions')) ->filter(fn (array $query): bool => str_starts_with(strtolower(ltrim($query['query'])), 'select')); expect($executionQueries)->toBeEmpty(); }); it('keeps a successful backup successful when retention cleanup fails', function () { config(['broadcasting.default' => 'null']); InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); $team = Team::factory()->create(); [$application, $volume] = createVolumeBackupApplication($team); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', 'disable_local_backup' => true, 'retention_amount_locally' => 1, ]); $oldExecution = ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 'status' => 'success', 'filename' => '/data/coolify/backups/volumes/test/old.tar.gz', 'size' => 64, 'created_at' => now()->subDay(), ]); Process::fake([ '*rm -f*' => Process::result(errorOutput: 'permission denied', exitCode: 1), '*du -b*' => '128', '*' => '', ]); (new VolumeBackupJob($backup))->handle(); $latestExecution = $backup->executions()->first(); expect($latestExecution->status)->toBe('success') ->and($latestExecution->filename)->not->toBeNull() ->and($oldExecution->fresh()->local_storage_deleted)->toBeFalse(); }); it('stops and restarts containers that use the volume when requested', function () { config(['broadcasting.default' => 'null']); InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); $team = Team::factory()->create(); [$application, $volume] = createVolumeBackupApplication($team); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', 'stop_during_backup' => true, ]); Process::fake([ '*docker ps -q*' => "abc123\ndef456", '*du -b*' => '128', '*' => '', ]); (new VolumeBackupJob($backup))->handle(); Process::assertRan(fn ($process) => str_contains($process->command, 'trap cleanup EXIT') && str_contains($process->command, 'exit 143') && str_contains($process->command, 'TERM') && str_contains($process->command, 'docker stop') && str_contains($process->command, 'docker start') && str_contains($process->command, 'state_file=')); Process::assertRan(fn ($process) => str_contains($process->command, '{{println .Source}}{{println .Name}}') && str_contains($process->command, "grep -Fqx -- 'app-data'") && str_contains($process->command, '{{.State.Paused}}') && str_contains($process->command, 'continue')); }); it('finds containers using a bind mounted host path before stopping', function () { config(['broadcasting.default' => 'null']); InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); $team = Team::factory()->create(); [$application, $volume] = createVolumeBackupApplication($team); $volume->update(['host_path' => '/srv/app-data']); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', 'stop_during_backup' => true, ]); Process::fake([ '*docker ps -q*' => 'abc123', '*du -b*' => '128', '*' => '', ]); (new VolumeBackupJob($backup))->handle(); Process::assertRan(fn ($process) => str_contains($process->command, '{{println .Source}}{{println .Name}}') && str_contains($process->command, "grep -Fqx -- '/srv/app-data'")); }); it('archives an application directory mount from its resolved host path', function () { config(['broadcasting.default' => 'null']); InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); $team = Team::factory()->create(); [$application] = createVolumeBackupApplication($team); $directory = LocalFileVolume::unguarded(fn () => LocalFileVolume::withoutEvents(fn () => LocalFileVolume::create([ 'uuid' => new_public_id(), 'fs_path' => './uploads', 'mount_path' => '/app/uploads', 'is_directory' => true, 'resource_id' => $application->id, 'resource_type' => $application->getMorphClass(), ]))); $backup = $directory->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', 'stop_during_backup' => true, ]); $source = $application->workdir().'/uploads'; Process::fake([ '*docker ps -q*' => 'abc123', '*du -b*' => '128', '*' => '', ]); (new VolumeBackupJob($backup))->handle(); expect(ScheduledVolumeBackupExecution::query()->sole()->status)->toBe('success'); Process::assertRan(fn ($process) => str_contains($process->command, 'test -d '.escapeshellarg($source))); Process::assertRan(fn ($process) => str_contains($process->command, $source) && str_contains($process->command, ':/volume:ro') && str_contains($process->command, 'directory-uploads-')); Process::assertRan(fn ($process) => str_contains($process->command, "grep -Fqx -- '".$source."'")); Process::assertRan(fn ($process) => str_contains($process->command, 'docker stop') && str_contains($process->command, 'docker start')); }); it('retries container recovery when a timed out backup left a container stopped', function () { Queue::fake(); $team = Team::factory()->create(); [$application, $volume] = createVolumeBackupApplication($team); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', ]); $execution = ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 'status' => 'running', 'stop_recovery_pending' => true, ]); Process::fake([ '*cat *coolify-volume-backup-*' => 'abc123', '*docker start*' => Process::result(errorOutput: 'daemon unavailable', exitCode: 1), ]); (new VolumeBackupJob($backup))->failed(new RuntimeException('Worker timed out')); expect($execution->fresh()->status)->toBe('failed') ->and($execution->fresh()->message)->toContain('Container recovery failed') ->and($execution->fresh()->stop_container_ids)->toBe(['abc123']) ->and($execution->fresh()->stop_recovery_pending)->toBeTrue(); Queue::assertPushed(VolumeBackupRecoveryJob::class); }); it('restarts containers and removes a partial archive when archiving fails', function () { config(['broadcasting.default' => 'null']); InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); $team = Team::factory()->create(); [$application, $volume] = createVolumeBackupApplication($team); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', 'stop_during_backup' => true, ]); Process::fake([ '*docker ps -q*' => 'abc123', '*trap cleanup EXIT*' => Process::result(errorOutput: 'archive failed', exitCode: 1), '*' => '', ]); expect(fn () => (new VolumeBackupJob($backup))->handle())->toThrow(RuntimeException::class); $execution = ScheduledVolumeBackupExecution::query()->sole(); expect($execution->status)->toBe('failed') ->and($execution->stop_container_ids)->toBeNull(); Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f') && str_contains($process->command, '.tar.gz')); }); it('throws when the S3 filesystem reports that deletion failed', function () { $disk = Mockery::mock(); $disk->shouldReceive('delete')->once()->andReturnFalse(); Storage::shouldReceive('build')->once()->andReturn($disk); $s3 = new S3Storage([ 'key' => 'key', 'secret' => 'secret', 'region' => 'us-east-1', 'bucket' => 'bucket', 'endpoint' => 'https://s3.example.com', ]); expect(fn () => deleteBackupsS3('archive.tar.gz', $s3)) ->toThrow(RuntimeException::class, 'could not be deleted'); }); it('cleans an interrupted S3 upload and coordinates recovery with the backup lock', function () { $team = Team::factory()->create(); [$application, $volume] = createVolumeBackupApplication($team); $s3Storage = S3Storage::create([ 'name' => 'Volume backups', 'region' => 'us-east-1', 'key' => 'key', 'secret' => 'secret', 'bucket' => 'bucket', 'endpoint' => 'https://s3.example.com', 'team_id' => $team->id, 'is_usable' => true, ]); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 's3_storage_id' => $s3Storage->id, 'frequency' => 'daily', 'save_s3' => true, ]); $execution = ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 's3_storage_id' => $s3Storage->id, 'status' => 'failed', 'filename' => '/data/coolify/backups/volumes/test/interrupted.tar.gz', 's3_cleanup_pending' => true, ]); $disk = Mockery::mock(); $disk->shouldReceive('delete')->once()->andReturnTrue(); Storage::shouldReceive('build')->once()->andReturn($disk); $job = new VolumeBackupRecoveryJob($execution); expect($job->middleware()[0]->getLockKey($job))->toBe(VolumeBackupJob::lockKey($backup->id)); $job->handle(); expect($execution->fresh()->s3_cleanup_pending)->toBeFalse() ->and($execution->fresh()->s3_storage_deleted)->toBeTrue(); }); it('cleans an interrupted upload from the execution S3 storage after a schedule switch', function () { $team = Team::factory()->create(); [$application, $volume] = createVolumeBackupApplication($team); $originalStorage = S3Storage::create([ 'name' => 'Original storage', 'region' => 'us-east-1', 'key' => 'original-key', 'secret' => 'secret', 'bucket' => 'original-bucket', 'endpoint' => 'https://s3.example.com', 'team_id' => $team->id, 'is_usable' => true, ]); $newStorage = S3Storage::create([ 'name' => 'New storage', 'region' => 'us-east-1', 'key' => 'new-key', 'secret' => 'secret', 'bucket' => 'new-bucket', 'endpoint' => 'https://s3.example.com', 'team_id' => $team->id, 'is_usable' => true, ]); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 's3_storage_id' => $newStorage->id, 'frequency' => 'daily', 'save_s3' => true, ]); $execution = ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 's3_storage_id' => $originalStorage->id, 'status' => 'failed', 'filename' => '/data/coolify/backups/volumes/test/interrupted.tar.gz', 's3_cleanup_pending' => true, ]); $disk = Mockery::mock(); $disk->shouldReceive('delete')->once()->andReturnTrue(); Storage::shouldReceive('build') ->once() ->with(Mockery::on(fn (array $config): bool => $config['key'] === 'original-key')) ->andReturn($disk); VolumeBackupRecoveryJob::cleanupS3Upload($execution); expect($execution->fresh()->s3_cleanup_pending)->toBeFalse() ->and($execution->fresh()->s3_storage_deleted)->toBeTrue(); }); it('keeps the S3 key tracked when interrupted upload cleanup must be retried', function () { Queue::fake(); Process::fake(); $team = Team::factory()->create(); [$application, $volume] = createVolumeBackupApplication($team); $s3Storage = S3Storage::create([ 'name' => 'Volume backups', 'region' => 'us-east-1', 'key' => 'key', 'secret' => 'secret', 'bucket' => 'bucket', 'endpoint' => 'https://s3.example.com', 'team_id' => $team->id, 'is_usable' => true, ]); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 's3_storage_id' => $s3Storage->id, 'frequency' => 'daily', 'save_s3' => true, ]); $execution = ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 's3_storage_id' => $s3Storage->id, 'status' => 'running', 'filename' => '/data/coolify/backups/volumes/test/interrupted.tar.gz', 's3_cleanup_pending' => true, ]); $disk = Mockery::mock(); $disk->shouldReceive('delete')->once()->andReturnFalse(); Storage::shouldReceive('build')->once()->andReturn($disk); (new VolumeBackupJob($backup))->failed(new RuntimeException('Worker timed out')); expect($execution->fresh()->filename)->toBe('/data/coolify/backups/volumes/test/interrupted.tar.gz') ->and($execution->fresh()->s3_cleanup_pending)->toBeTrue(); Queue::assertPushed(VolumeBackupRecoveryJob::class); }); it('dispatches due scheduled volume backups', function () { config(['constants.coolify.self_hosted' => true]); Carbon::setTestNow('2026-07-15 12:00:00'); Queue::fake(); $team = Team::factory()->create(); [$application, $volume, $server] = createVolumeBackupApplication($team); $server->settings()->update([ 'is_reachable' => true, 'is_usable' => true, 'force_disabled' => false, ]); expect($server->fresh()->isFunctional())->toBeTrue(); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => '* * * * *', 'enabled' => true, ]); Cache::forget("scheduled-volume-backup:{$backup->id}"); expect($backup->server()?->isFunctional())->toBeTrue(); (new ScheduledJobManager)->handle(); Queue::assertPushed( VolumeBackupJob::class, fn (VolumeBackupJob $job) => $job->backup->is($backup), ); }); it('dispatches due scheduled directory backups', function () { config(['constants.coolify.self_hosted' => true]); Carbon::setTestNow('2026-07-15 12:00:00'); Queue::fake(); $team = Team::factory()->create(); [$application, $volume, $server] = createVolumeBackupApplication($team); $directory = createApplicationBackupDirectory($application); $server->settings()->update([ 'is_reachable' => true, 'is_usable' => true, 'force_disabled' => false, ]); $backup = $directory->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => '* * * * *', 'enabled' => true, ]); Cache::forget("scheduled-volume-backup:{$backup->id}"); (new ScheduledJobManager)->handle(); Queue::assertPushed( VolumeBackupJob::class, fn (VolumeBackupJob $job) => $job->backup->is($backup), ); }); it('retains scheduled volume backups and archive metadata when the server is missing', function () { config(['constants.coolify.self_hosted' => true]); Queue::fake(); $team = Team::factory()->create(); [$application, $volume] = createVolumeBackupApplication($team); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => '* * * * *', 'enabled' => true, ]); $execution = ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 'status' => 'success', 'filename' => '/data/coolify/backups/volumes/test/archive.tar.gz', ]); $application->update([ 'destination_id' => null, 'destination_type' => null, ]); (new ScheduledJobManager)->handle(); expect($backup->fresh())->not->toBeNull() ->and($execution->fresh())->not->toBeNull() ->and($execution->fresh()->filename)->toBe('/data/coolify/backups/volumes/test/archive.tar.gz'); Queue::assertNotPushed(VolumeBackupJob::class); }); it('dispatches pending recovery without starting another volume backup', function () { config(['constants.coolify.self_hosted' => true]); Queue::fake(); $team = Team::factory()->create(); [$application, $volume, $server] = createVolumeBackupApplication($team); $server->settings()->update([ 'is_reachable' => true, 'is_usable' => true, 'force_disabled' => false, ]); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => '* * * * *', 'enabled' => true, ]); $execution = ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 'status' => 'failed', 'stop_recovery_pending' => true, ]); (new ScheduledJobManager)->handle(); Queue::assertPushed( VolumeBackupRecoveryJob::class, fn (VolumeBackupRecoveryJob $job) => $job->execution->is($execution), ); Queue::assertNotPushed(VolumeBackupJob::class); });