From 239a2e6af9fcccc02f41fed8983c90384f764909 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 25 Dec 2025 03:09:33 +0100 Subject: [PATCH] feat: backup sets bulk restore/force delete - Add archived-only bulk restore + force delete actions - Add jobs with tenant isolation + skip reasons - Restore also restores backup items; force delete removes items - Add unit + feature tests --- app/Filament/Resources/BackupSetResource.php | 101 +++++++++++ app/Jobs/BulkBackupSetForceDeleteJob.php | 160 ++++++++++++++++++ app/Jobs/BulkBackupSetRestoreJob.php | 152 +++++++++++++++++ specs/005-bulk-operations/tasks.md | 8 + .../Feature/BulkForceDeleteBackupSetsTest.php | 55 ++++++ tests/Feature/BulkRestoreBackupSetsTest.php | 58 +++++++ .../Unit/BulkBackupSetForceDeleteJobTest.php | 107 ++++++++++++ tests/Unit/BulkBackupSetRestoreJobTest.php | 105 ++++++++++++ 8 files changed, 746 insertions(+) create mode 100644 app/Jobs/BulkBackupSetForceDeleteJob.php create mode 100644 app/Jobs/BulkBackupSetRestoreJob.php create mode 100644 tests/Feature/BulkForceDeleteBackupSetsTest.php create mode 100644 tests/Feature/BulkRestoreBackupSetsTest.php create mode 100644 tests/Unit/BulkBackupSetForceDeleteJobTest.php create mode 100644 tests/Unit/BulkBackupSetRestoreJobTest.php diff --git a/app/Filament/Resources/BackupSetResource.php b/app/Filament/Resources/BackupSetResource.php index 83900d4..d2bdbdd 100644 --- a/app/Filament/Resources/BackupSetResource.php +++ b/app/Filament/Resources/BackupSetResource.php @@ -5,6 +5,8 @@ use App\Filament\Resources\BackupSetResource\Pages; use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager; use App\Jobs\BulkBackupSetDeleteJob; +use App\Jobs\BulkBackupSetForceDeleteJob; +use App\Jobs\BulkBackupSetRestoreJob; use App\Models\BackupSet; use App\Models\Tenant; use App\Services\BulkOperationService; @@ -199,6 +201,105 @@ public static function table(Table $table): Table } }) ->deselectRecordsAfterCompletion(), + + BulkAction::make('bulk_restore') + ->label('Restore Backup Sets') + ->icon('heroicon-o-arrow-uturn-left') + ->color('success') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; + + $isOnlyTrashed = in_array($value, [0, '0', false], true); + + return ! $isOnlyTrashed; + }) + ->modalHeading(fn (Collection $records) => "Restore {$records->count()} backup sets?") + ->modalDescription('Archived backup sets will be restored back to the active list. Active backup sets will be skipped.') + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'backup_set', 'restore', $ids, $count); + + if ($count >= 10) { + Notification::make() + ->title('Bulk restore started') + ->body("Restoring {$count} backup sets in the background. Check the progress bar in the bottom right corner.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() + ->duration(8000) + ->sendToDatabase($user) + ->send(); + + BulkBackupSetRestoreJob::dispatch($run->id); + } else { + BulkBackupSetRestoreJob::dispatchSync($run->id); + } + }) + ->deselectRecordsAfterCompletion(), + + BulkAction::make('bulk_force_delete') + ->label('Force Delete Backup Sets') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; + + $isOnlyTrashed = in_array($value, [0, '0', false], true); + + return ! $isOnlyTrashed; + }) + ->modalHeading(fn (Collection $records) => "Force delete {$records->count()} backup sets?") + ->modalDescription('This is permanent. Only archived backup sets will be permanently deleted; active backup sets will be skipped.') + ->form(function (Collection $records) { + if ($records->count() >= 10) { + return [ + Forms\Components\TextInput::make('confirmation') + ->label('Type DELETE to confirm') + ->required() + ->in(['DELETE']) + ->validationMessages([ + 'in' => 'Please type DELETE to confirm.', + ]), + ]; + } + + return []; + }) + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'backup_set', 'force_delete', $ids, $count); + + if ($count >= 10) { + Notification::make() + ->title('Bulk force delete started') + ->body("Force deleting {$count} backup sets in the background. Check the progress bar in the bottom right corner.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() + ->duration(8000) + ->sendToDatabase($user) + ->send(); + + BulkBackupSetForceDeleteJob::dispatch($run->id); + } else { + BulkBackupSetForceDeleteJob::dispatchSync($run->id); + } + }) + ->deselectRecordsAfterCompletion(), ]), ]); } diff --git a/app/Jobs/BulkBackupSetForceDeleteJob.php b/app/Jobs/BulkBackupSetForceDeleteJob.php new file mode 100644 index 0000000..0cd3d22 --- /dev/null +++ b/app/Jobs/BulkBackupSetForceDeleteJob.php @@ -0,0 +1,160 @@ +find($this->bulkRunId); + + if (! $run || $run->status !== 'pending') { + return; + } + + $service->start($run); + + $itemCount = 0; + $succeeded = 0; + $failed = 0; + $skipped = 0; + $skipReasons = []; + + $chunkSize = 10; + $totalItems = $run->total_items ?: count($run->item_ids ?? []); + $failureThreshold = (int) floor($totalItems / 2); + + foreach (($run->item_ids ?? []) as $backupSetId) { + $itemCount++; + + try { + /** @var BackupSet|null $backupSet */ + $backupSet = BackupSet::withTrashed() + ->where('tenant_id', $run->tenant_id) + ->whereKey($backupSetId) + ->first(); + + if (! $backupSet) { + $service->recordFailure($run, (string) $backupSetId, 'Backup set not found'); + $failed++; + + if ($failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Force Delete Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + + continue; + } + + if (! $backupSet->trashed()) { + $service->recordSkippedWithReason($run, (string) $backupSet->id, 'Not archived'); + $skipped++; + $skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1; + + continue; + } + + if ($backupSet->restoreRuns()->withTrashed()->exists()) { + $service->recordSkippedWithReason($run, (string) $backupSet->id, 'Referenced by restore runs'); + $skipped++; + $skipReasons['Referenced by restore runs'] = ($skipReasons['Referenced by restore runs'] ?? 0) + 1; + + continue; + } + + $backupSet->items()->withTrashed()->forceDelete(); + $backupSet->forceDelete(); + + $service->recordSuccess($run); + $succeeded++; + } catch (Throwable $e) { + $service->recordFailure($run, (string) $backupSetId, $e->getMessage()); + $failed++; + + if ($failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Force Delete Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + } + + if ($itemCount % $chunkSize === 0) { + $run->refresh(); + } + } + + $service->complete($run); + + if (! $run->user) { + return; + } + + $message = "Force deleted {$succeeded} backup sets"; + if ($skipped > 0) { + $message .= " ({$skipped} skipped)"; + } + if ($failed > 0) { + $message .= " ({$failed} failed)"; + } + + if (! empty($skipReasons)) { + $summary = collect($skipReasons) + ->sortDesc() + ->map(fn (int $count, string $reason) => "{$reason} ({$count})") + ->take(3) + ->implode(', '); + + if ($summary !== '') { + $message .= " Skip reasons: {$summary}."; + } + } + + $message .= '.'; + + Notification::make() + ->title('Bulk Force Delete Completed') + ->body($message) + ->icon('heroicon-o-check-circle') + ->success() + ->sendToDatabase($run->user) + ->send(); + } +} diff --git a/app/Jobs/BulkBackupSetRestoreJob.php b/app/Jobs/BulkBackupSetRestoreJob.php new file mode 100644 index 0000000..e028892 --- /dev/null +++ b/app/Jobs/BulkBackupSetRestoreJob.php @@ -0,0 +1,152 @@ +find($this->bulkRunId); + + if (! $run || $run->status !== 'pending') { + return; + } + + $service->start($run); + + $itemCount = 0; + $succeeded = 0; + $failed = 0; + $skipped = 0; + $skipReasons = []; + + $chunkSize = 10; + $totalItems = $run->total_items ?: count($run->item_ids ?? []); + $failureThreshold = (int) floor($totalItems / 2); + + foreach (($run->item_ids ?? []) as $backupSetId) { + $itemCount++; + + try { + /** @var BackupSet|null $backupSet */ + $backupSet = BackupSet::withTrashed() + ->where('tenant_id', $run->tenant_id) + ->whereKey($backupSetId) + ->first(); + + if (! $backupSet) { + $service->recordFailure($run, (string) $backupSetId, 'Backup set not found'); + $failed++; + + if ($failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Restore Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + + continue; + } + + if (! $backupSet->trashed()) { + $service->recordSkippedWithReason($run, (string) $backupSet->id, 'Not archived'); + $skipped++; + $skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1; + + continue; + } + + $backupSet->restore(); + $backupSet->items()->withTrashed()->restore(); + + $service->recordSuccess($run); + $succeeded++; + } catch (Throwable $e) { + $service->recordFailure($run, (string) $backupSetId, $e->getMessage()); + $failed++; + + if ($failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Restore Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + } + + if ($itemCount % $chunkSize === 0) { + $run->refresh(); + } + } + + $service->complete($run); + + if (! $run->user) { + return; + } + + $message = "Restored {$succeeded} backup sets"; + if ($skipped > 0) { + $message .= " ({$skipped} skipped)"; + } + if ($failed > 0) { + $message .= " ({$failed} failed)"; + } + + if (! empty($skipReasons)) { + $summary = collect($skipReasons) + ->sortDesc() + ->map(fn (int $count, string $reason) => "{$reason} ({$count})") + ->take(3) + ->implode(', '); + + if ($summary !== '') { + $message .= " Skip reasons: {$summary}."; + } + } + + $message .= '.'; + + Notification::make() + ->title('Bulk Restore Completed') + ->body($message) + ->icon('heroicon-o-check-circle') + ->success() + ->sendToDatabase($run->user) + ->send(); + } +} diff --git a/specs/005-bulk-operations/tasks.md b/specs/005-bulk-operations/tasks.md index c044d94..aad316c 100644 --- a/specs/005-bulk-operations/tasks.md +++ b/specs/005-bulk-operations/tasks.md @@ -265,6 +265,14 @@ ### Implementation for Additional Resource - [ ] T084 Test delete with 15 backup sets (manual QA) - [x] T085 Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteBackupSetsTest.php` +- [x] T085e Add bulk restore backup sets job in app/Jobs/BulkBackupSetRestoreJob.php +- [x] T085f Add bulk restore action (archived-only) to BackupSetResource in app/Filament/Resources/BackupSetResource.php +- [x] T085g Write unit+feature tests for bulk restore in tests/Unit/BulkBackupSetRestoreJobTest.php and tests/Feature/BulkRestoreBackupSetsTest.php + +- [x] T085h Add bulk force delete backup sets job in app/Jobs/BulkBackupSetForceDeleteJob.php +- [x] T085i Add bulk force delete action (archived-only) to BackupSetResource in app/Filament/Resources/BackupSetResource.php +- [x] T085j Write unit+feature tests for bulk force delete in tests/Unit/BulkBackupSetForceDeleteJobTest.php and tests/Feature/BulkForceDeleteBackupSetsTest.php + ### Additional: Bulk Archive Backup Sets (FR-005.23) - [ ] T085a Verify BackupSet has `archived_at` column; add migration if missing in database/migrations/ diff --git a/tests/Feature/BulkForceDeleteBackupSetsTest.php b/tests/Feature/BulkForceDeleteBackupSetsTest.php new file mode 100644 index 0000000..fcb9946 --- /dev/null +++ b/tests/Feature/BulkForceDeleteBackupSetsTest.php @@ -0,0 +1,55 @@ +create(); + $user = User::factory()->create(); + + $set = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $item = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $set->id, + 'policy_id' => null, + 'policy_identifier' => 'policy-1', + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows10', + 'payload' => ['id' => 'policy-1'], + 'metadata' => null, + ]); + + $set->delete(); + + Livewire::actingAs($user) + ->test(BackupSetResource\Pages\ListBackupSets::class) + ->filterTable(\Filament\Tables\Filters\TrashedFilter::class, false) + ->callTableBulkAction('bulk_force_delete', collect([$set])) + ->assertHasNoTableBulkActionErrors(); + + expect(BackupSet::withTrashed()->find($set->id))->toBeNull(); + expect(BackupItem::withTrashed()->find($item->id))->toBeNull(); + + $bulkRun = BulkOperationRun::query() + ->where('resource', 'backup_set') + ->where('action', 'force_delete') + ->latest('id') + ->first(); + + expect($bulkRun)->not->toBeNull(); + expect($bulkRun->status)->toBe('completed'); +}); diff --git a/tests/Feature/BulkRestoreBackupSetsTest.php b/tests/Feature/BulkRestoreBackupSetsTest.php new file mode 100644 index 0000000..3cda81b --- /dev/null +++ b/tests/Feature/BulkRestoreBackupSetsTest.php @@ -0,0 +1,58 @@ +create(); + $user = User::factory()->create(); + + $set = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $item = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $set->id, + 'policy_id' => null, + 'policy_identifier' => 'policy-1', + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows10', + 'payload' => ['id' => 'policy-1'], + 'metadata' => null, + ]); + + $set->delete(); + + Livewire::actingAs($user) + ->test(BackupSetResource\Pages\ListBackupSets::class) + ->filterTable(\Filament\Tables\Filters\TrashedFilter::class, false) + ->callTableBulkAction('bulk_restore', collect([$set])) + ->assertHasNoTableBulkActionErrors(); + + $set->refresh(); + $item->refresh(); + + expect($set->trashed())->toBeFalse(); + expect($item->trashed())->toBeFalse(); + + $bulkRun = BulkOperationRun::query() + ->where('resource', 'backup_set') + ->where('action', 'restore') + ->latest('id') + ->first(); + + expect($bulkRun)->not->toBeNull(); + expect($bulkRun->status)->toBe('completed'); +}); diff --git a/tests/Unit/BulkBackupSetForceDeleteJobTest.php b/tests/Unit/BulkBackupSetForceDeleteJobTest.php new file mode 100644 index 0000000..7a2720a --- /dev/null +++ b/tests/Unit/BulkBackupSetForceDeleteJobTest.php @@ -0,0 +1,107 @@ +create(['is_current' => true]); + $user = User::factory()->create(); + + $set = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $item = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $set->id, + 'policy_id' => null, + 'policy_identifier' => 'policy-1', + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows10', + 'payload' => ['id' => 'policy-1'], + 'metadata' => null, + ]); + + $set->delete(); + + $service = app(BulkOperationService::class); + $run = BulkOperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'resource' => 'backup_set', + 'action' => 'force_delete', + 'status' => 'pending', + 'total_items' => 1, + 'item_ids' => [$set->id], + 'failures' => [], + ]); + + (new BulkBackupSetForceDeleteJob($run->id))->handle($service); + + expect(BackupSet::withTrashed()->find($set->id))->toBeNull(); + expect(BackupItem::withTrashed()->find($item->id))->toBeNull(); + + $run->refresh(); + expect($run->status)->toBe('completed') + ->and($run->succeeded)->toBe(1) + ->and($run->skipped)->toBe(0) + ->and($run->failed)->toBe(0); +}); + +test('bulk backup set force delete job skips sets referenced by restore runs', function () { + $tenant = Tenant::factory()->create(['is_current' => true]); + $user = User::factory()->create(); + + $set = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $set->delete(); + + RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $set->id, + 'status' => 'completed', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + $service = app(BulkOperationService::class); + $run = BulkOperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'resource' => 'backup_set', + 'action' => 'force_delete', + 'status' => 'pending', + 'total_items' => 1, + 'item_ids' => [$set->id], + 'failures' => [], + ]); + + (new BulkBackupSetForceDeleteJob($run->id))->handle($service); + + $run->refresh(); + expect($run->status)->toBe('completed') + ->and($run->succeeded)->toBe(0) + ->and($run->skipped)->toBe(1) + ->and($run->failed)->toBe(0); + + expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeTrue(); + expect(collect($run->failures)->pluck('reason')->all())->toContain('Referenced by restore runs'); +}); diff --git a/tests/Unit/BulkBackupSetRestoreJobTest.php b/tests/Unit/BulkBackupSetRestoreJobTest.php new file mode 100644 index 0000000..7966e4c --- /dev/null +++ b/tests/Unit/BulkBackupSetRestoreJobTest.php @@ -0,0 +1,105 @@ +create(['is_current' => true]); + $user = User::factory()->create(); + + $set = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $item = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $set->id, + 'policy_id' => null, + 'policy_identifier' => 'policy-1', + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows10', + 'payload' => ['id' => 'policy-1'], + 'metadata' => null, + ]); + + $set->delete(); + + $set->refresh(); + expect($set->trashed())->toBeTrue(); + expect(BackupItem::withTrashed()->find($item->id)?->trashed())->toBeTrue(); + + $service = app(BulkOperationService::class); + $run = BulkOperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'resource' => 'backup_set', + 'action' => 'restore', + 'status' => 'pending', + 'total_items' => 1, + 'item_ids' => [$set->id], + 'failures' => [], + ]); + + (new BulkBackupSetRestoreJob($run->id))->handle($service); + + $set->refresh(); + expect($set->trashed())->toBeFalse(); + + $item->refresh(); + expect($item->trashed())->toBeFalse(); + + $run->refresh(); + expect($run->status)->toBe('completed') + ->and($run->succeeded)->toBe(1) + ->and($run->skipped)->toBe(0) + ->and($run->failed)->toBe(0); +}); + +test('bulk backup set restore job skips active sets', function () { + $tenant = Tenant::factory()->create(['is_current' => true]); + $user = User::factory()->create(); + + $set = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $service = app(BulkOperationService::class); + $run = BulkOperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'resource' => 'backup_set', + 'action' => 'restore', + 'status' => 'pending', + 'total_items' => 1, + 'item_ids' => [$set->id], + 'failures' => [], + ]); + + (new BulkBackupSetRestoreJob($run->id))->handle($service); + + $set->refresh(); + expect($set->trashed())->toBeFalse(); + + $run->refresh(); + expect($run->status)->toBe('completed') + ->and($run->succeeded)->toBe(0) + ->and($run->skipped)->toBe(1) + ->and($run->failed)->toBe(0); + + expect(collect($run->failures)->pluck('reason')->all())->toContain('Not archived'); +});