From e603a1245eef23e6657f908b3f2101b7726bfcfd Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 25 Dec 2025 02:59:31 +0100 Subject: [PATCH] feat: restore runs bulk archive/restore/force delete - Add bulk restore + archived-only force delete actions - Add jobs + tests for bulk restore/force delete - Treat restore_run status 'partial' as deletable for hygiene - Update feature tasks checklist --- app/Filament/Resources/RestoreRunResource.php | 178 +++++++++++++++++- app/Jobs/BulkRestoreRunDeleteJob.php | 177 +++++++++++++++++ app/Jobs/BulkRestoreRunForceDeleteJob.php | 150 +++++++++++++++ app/Jobs/BulkRestoreRunRestoreJob.php | 150 +++++++++++++++ app/Models/RestoreRun.php | 7 +- specs/005-bulk-operations/tasks.md | 28 ++- tests/Feature/BulkDeleteMixedStatusTest.php | 61 ++++++ tests/Feature/BulkDeleteRestoreRunsTest.php | 50 +++++ .../BulkForceDeleteRestoreRunsTest.php | 57 ++++++ tests/Feature/BulkRestoreRestoreRunsTest.php | 57 ++++++ tests/Feature/RestoreRunArchiveGuardTest.php | 37 ++++ tests/Unit/BulkRestoreRunDeleteJobTest.php | 94 +++++++++ tests/Unit/BulkRestoreRunRestoreJobTest.php | 102 ++++++++++ tests/Unit/RestoreRunDeletableTest.php | 56 ++++++ 14 files changed, 1191 insertions(+), 13 deletions(-) create mode 100644 app/Jobs/BulkRestoreRunDeleteJob.php create mode 100644 app/Jobs/BulkRestoreRunForceDeleteJob.php create mode 100644 app/Jobs/BulkRestoreRunRestoreJob.php create mode 100644 tests/Feature/BulkDeleteMixedStatusTest.php create mode 100644 tests/Feature/BulkDeleteRestoreRunsTest.php create mode 100644 tests/Feature/BulkForceDeleteRestoreRunsTest.php create mode 100644 tests/Feature/BulkRestoreRestoreRunsTest.php create mode 100644 tests/Feature/RestoreRunArchiveGuardTest.php create mode 100644 tests/Unit/BulkRestoreRunDeleteJobTest.php create mode 100644 tests/Unit/BulkRestoreRunRestoreJobTest.php create mode 100644 tests/Unit/RestoreRunDeletableTest.php diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index bad0ae9..4174710 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -3,14 +3,20 @@ namespace App\Filament\Resources; use App\Filament\Resources\RestoreRunResource\Pages; +use App\Jobs\BulkRestoreRunDeleteJob; +use App\Jobs\BulkRestoreRunForceDeleteJob; +use App\Jobs\BulkRestoreRunRestoreJob; use App\Models\BackupItem; use App\Models\BackupSet; use App\Models\RestoreRun; use App\Models\Tenant; +use App\Services\BulkOperationService; use App\Services\Intune\RestoreService; use BackedEnum; use Filament\Actions; use Filament\Actions\ActionGroup; +use Filament\Actions\BulkAction; +use Filament\Actions\BulkActionGroup; use Filament\Forms; use Filament\Infolists; use Filament\Notifications\Notification; @@ -18,7 +24,10 @@ use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Schema; use Filament\Tables; +use Filament\Tables\Contracts\HasTable; +use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Collection; use UnitEnum; class RestoreRunResource extends Resource @@ -105,7 +114,11 @@ public static function table(Table $table): Table Tables\Columns\TextColumn::make('requested_by')->label('Requested by'), ]) ->filters([ - Tables\Filters\TrashedFilter::make(), + TrashedFilter::make() + ->label('Archived') + ->placeholder('Active') + ->trueLabel('All') + ->falseLabel('Archived'), ]) ->actions([ Actions\ViewAction::make(), @@ -117,6 +130,16 @@ public static function table(Table $table): Table ->requiresConfirmation() ->visible(fn (RestoreRun $record) => ! $record->trashed()) ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { + if (! $record->isDeletable()) { + Notification::make() + ->title('Restore run cannot be archived') + ->body("Not deletable (status: {$record->status})") + ->warning() + ->send(); + + return; + } + $record->delete(); if ($record->tenant) { @@ -162,7 +185,158 @@ public static function table(Table $table): Table }), ])->icon('heroicon-o-ellipsis-vertical'), ]) - ->bulkActions([]); + ->bulkActions([ + BulkActionGroup::make([ + BulkAction::make('bulk_delete') + ->label('Archive Restore Runs') + ->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; + }) + ->modalDescription('This archives restore runs (soft delete). Already archived runs will be skipped.') + ->form(function (Collection $records) { + if ($records->count() >= 20) { + 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, 'restore_run', 'delete', $ids, $count); + + if ($count >= 20) { + Notification::make() + ->title('Bulk delete started') + ->body("Deleting {$count} restore runs 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(); + + BulkRestoreRunDeleteJob::dispatch($run->id); + } else { + BulkRestoreRunDeleteJob::dispatchSync($run->id); + } + }) + ->deselectRecordsAfterCompletion(), + + BulkAction::make('bulk_restore') + ->label('Restore Restore Runs') + ->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()} restore runs?") + ->modalDescription('Archived runs will be restored back to the active list. Active runs 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, 'restore_run', 'restore', $ids, $count); + + if ($count >= 20) { + Notification::make() + ->title('Bulk restore started') + ->body("Restoring {$count} restore runs 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(); + + BulkRestoreRunRestoreJob::dispatch($run->id); + } else { + BulkRestoreRunRestoreJob::dispatchSync($run->id); + } + }) + ->deselectRecordsAfterCompletion(), + + BulkAction::make('bulk_force_delete') + ->label('Force Delete Restore Runs') + ->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()} restore runs?") + ->modalDescription('This is permanent. Only archived restore runs will be permanently deleted; active runs will be skipped.') + ->form([ + Forms\Components\TextInput::make('confirmation') + ->label('Type DELETE to confirm') + ->required() + ->in(['DELETE']) + ->validationMessages([ + 'in' => 'Please type DELETE to confirm.', + ]), + ]) + ->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, 'restore_run', 'force_delete', $ids, $count); + + if ($count >= 20) { + Notification::make() + ->title('Bulk force delete started') + ->body("Force deleting {$count} restore runs 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(); + + BulkRestoreRunForceDeleteJob::dispatch($run->id); + } else { + BulkRestoreRunForceDeleteJob::dispatchSync($run->id); + } + }) + ->deselectRecordsAfterCompletion(), + ]), + ]); } public static function infolist(Schema $schema): Schema diff --git a/app/Jobs/BulkRestoreRunDeleteJob.php b/app/Jobs/BulkRestoreRunDeleteJob.php new file mode 100644 index 0000000..e5e3ec3 --- /dev/null +++ b/app/Jobs/BulkRestoreRunDeleteJob.php @@ -0,0 +1,177 @@ +find($this->bulkRunId); + + if (! $run || $run->status !== 'pending') { + return; + } + + $service->start($run); + + try { + $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 $restoreRunId) { + $itemCount++; + + try { + /** @var RestoreRun|null $restoreRun */ + $restoreRun = RestoreRun::withTrashed() + ->where('tenant_id', $run->tenant_id) + ->whereKey($restoreRunId) + ->first(); + + if (! $restoreRun) { + $service->recordFailure($run, (string) $restoreRunId, 'Restore run not found'); + $failed++; + + if ($failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Delete Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + + continue; + } + + if ($restoreRun->trashed()) { + $service->recordSkippedWithReason($run, (string) $restoreRun->id, 'Already archived'); + $skipped++; + $skipReasons['Already archived'] = ($skipReasons['Already archived'] ?? 0) + 1; + + continue; + } + + if (! $restoreRun->isDeletable()) { + $reason = "Not deletable (status: {$restoreRun->status})"; + + $service->recordSkippedWithReason($run, (string) $restoreRun->id, $reason); + $skipped++; + $skipReasons[$reason] = ($skipReasons[$reason] ?? 0) + 1; + + continue; + } + + $restoreRun->delete(); + $service->recordSuccess($run); + $succeeded++; + } catch (Throwable $e) { + $service->recordFailure($run, (string) $restoreRunId, $e->getMessage()); + $failed++; + + if ($failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk 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) { + $message = "Deleted {$succeeded} restore runs"; + 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 Delete Completed') + ->body($message) + ->icon('heroicon-o-check-circle') + ->success() + ->sendToDatabase($run->user) + ->send(); + } + } catch (Throwable $e) { + $service->fail($run, $e->getMessage()); + + $run->refresh(); + $run->load('user'); + + if ($run->user) { + Notification::make() + ->title('Bulk Delete Failed') + ->body($e->getMessage()) + ->icon('heroicon-o-x-circle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + throw $e; + } + } +} diff --git a/app/Jobs/BulkRestoreRunForceDeleteJob.php b/app/Jobs/BulkRestoreRunForceDeleteJob.php new file mode 100644 index 0000000..d96d5a0 --- /dev/null +++ b/app/Jobs/BulkRestoreRunForceDeleteJob.php @@ -0,0 +1,150 @@ +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 $restoreRunId) { + $itemCount++; + + try { + /** @var RestoreRun|null $restoreRun */ + $restoreRun = RestoreRun::withTrashed() + ->where('tenant_id', $run->tenant_id) + ->whereKey($restoreRunId) + ->first(); + + if (! $restoreRun) { + $service->recordFailure($run, (string) $restoreRunId, 'Restore run 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 (! $restoreRun->trashed()) { + $service->recordSkippedWithReason($run, (string) $restoreRun->id, 'Not archived'); + $skipped++; + $skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1; + + continue; + } + + $restoreRun->forceDelete(); + $service->recordSuccess($run); + $succeeded++; + } catch (Throwable $e) { + $service->recordFailure($run, (string) $restoreRunId, $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} restore runs"; + 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/BulkRestoreRunRestoreJob.php b/app/Jobs/BulkRestoreRunRestoreJob.php new file mode 100644 index 0000000..8bf4229 --- /dev/null +++ b/app/Jobs/BulkRestoreRunRestoreJob.php @@ -0,0 +1,150 @@ +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 $restoreRunId) { + $itemCount++; + + try { + /** @var RestoreRun|null $restoreRun */ + $restoreRun = RestoreRun::withTrashed() + ->where('tenant_id', $run->tenant_id) + ->whereKey($restoreRunId) + ->first(); + + if (! $restoreRun) { + $service->recordFailure($run, (string) $restoreRunId, 'Restore run 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 (! $restoreRun->trashed()) { + $service->recordSkippedWithReason($run, (string) $restoreRun->id, 'Not archived'); + $skipped++; + $skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1; + + continue; + } + + $restoreRun->restore(); + $service->recordSuccess($run); + $succeeded++; + } catch (Throwable $e) { + $service->recordFailure($run, (string) $restoreRunId, $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} restore runs"; + 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/app/Models/RestoreRun.php b/app/Models/RestoreRun.php index 244330c..ce7fb50 100644 --- a/app/Models/RestoreRun.php +++ b/app/Models/RestoreRun.php @@ -36,6 +36,11 @@ public function backupSet(): BelongsTo public function scopeDeletable($query) { - return $query->whereIn('status', ['completed', 'failed', 'aborted', 'completed_with_errors']); + return $query->whereIn('status', ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial']); + } + + public function isDeletable(): bool + { + return in_array($this->status, ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial'], true); } } diff --git a/specs/005-bulk-operations/tasks.md b/specs/005-bulk-operations/tasks.md index 7e6eaf7..e58d7ad 100644 --- a/specs/005-bulk-operations/tasks.md +++ b/specs/005-bulk-operations/tasks.md @@ -218,20 +218,28 @@ ## Phase 8: User Story 4 - Bulk Delete Restore Runs (Priority: P2) ### Tests for User Story 4 -- [ ] T067 [P] [US4] Write unit test for deletable() scope in tests/Unit/RestoreRunDeletableTest.php -- [ ] T068 [P] [US4] Write unit test for BulkRestoreRunDeleteJob in tests/Unit/BulkRestoreRunDeleteJobTest.php -- [ ] T069 [P] [US4] Write feature test for bulk delete restore runs in tests/Feature/BulkDeleteRestoreRunsTest.php -- [ ] T070 [P] [US4] Write test for mixed statuses (skip running) in tests/Feature/BulkDeleteMixedStatusTest.php +- [x] T067 [P] [US4] Write unit test for deletable() scope in tests/Unit/RestoreRunDeletableTest.php +- [x] T068 [P] [US4] Write unit test for BulkRestoreRunDeleteJob in tests/Unit/BulkRestoreRunDeleteJobTest.php +- [x] T069 [P] [US4] Write feature test for bulk delete restore runs in tests/Feature/BulkDeleteRestoreRunsTest.php +- [x] T070 [P] [US4] Write test for mixed statuses (skip running) in tests/Feature/BulkDeleteMixedStatusTest.php ### Implementation for User Story 4 -- [ ] T071 [P] [US4] Create BulkRestoreRunDeleteJob in app/Jobs/BulkRestoreRunDeleteJob.php -- [ ] T072 [US4] Add bulk delete action to RestoreRunResource in app/Filament/Resources/RestoreRunResource.php -- [ ] T073 [US4] Filter by deletable() scope (completed, failed, aborted only) -- [ ] T074 [US4] Skip running restore runs with warning -- [ ] T075 [US4] Add type-to-confirm for ≥20 runs +- [x] T071 [P] [US4] Create BulkRestoreRunDeleteJob in app/Jobs/BulkRestoreRunDeleteJob.php +- [x] T072 [US4] Add bulk delete action to RestoreRunResource in app/Filament/Resources/RestoreRunResource.php +- [x] T073 [US4] Filter by deletable() scope (completed, failed, aborted only) +- [x] T074 [US4] Skip running restore runs with warning +- [x] T075 [US4] Add type-to-confirm for ≥20 runs - [ ] T076 [US4] Test delete with 20 completed + 5 running (manual QA, verify 5 skipped) -- [ ] T077 [US4] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteRestoreRunsTest.php` +- [x] T077 [US4] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteRestoreRunsTest.php` + +- [x] T077a [US4] Add bulk force delete restore runs job in app/Jobs/BulkRestoreRunForceDeleteJob.php +- [x] T077b [US4] Add bulk force delete action (archived-only) to RestoreRunResource in app/Filament/Resources/RestoreRunResource.php +- [x] T077c [US4] Write feature test for bulk force delete in tests/Feature/BulkForceDeleteRestoreRunsTest.php + +- [x] T077d [US4] Add bulk restore restore runs job in app/Jobs/BulkRestoreRunRestoreJob.php +- [x] T077e [US4] Add bulk restore action (archived-only) to RestoreRunResource in app/Filament/Resources/RestoreRunResource.php +- [x] T077f [US4] Write unit+feature tests for bulk restore in tests/Unit/BulkRestoreRunRestoreJobTest.php and tests/Feature/BulkRestoreRestoreRunsTest.php **Checkpoint**: Restore runs bulk delete working, running runs protected, skip warnings shown diff --git a/tests/Feature/BulkDeleteMixedStatusTest.php b/tests/Feature/BulkDeleteMixedStatusTest.php new file mode 100644 index 0000000..be7dbbb --- /dev/null +++ b/tests/Feature/BulkDeleteMixedStatusTest.php @@ -0,0 +1,61 @@ +create(); + $user = User::factory()->create(); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $completedRuns = collect(range(1, 3))->map(function () use ($tenant, $backupSet) { + return RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'completed', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + }); + + $running = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'running', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + $records = $completedRuns->concat([$running]); + + Livewire::actingAs($user) + ->test(RestoreRunResource\Pages\ListRestoreRuns::class) + ->callTableBulkAction('bulk_delete', $records) + ->assertHasNoTableBulkActionErrors(); + + $completedRuns->each(fn (RestoreRun $run) => expect(RestoreRun::withTrashed()->find($run->id)?->trashed())->toBeTrue()); + expect(RestoreRun::withTrashed()->find($running->id)?->trashed())->toBeFalse(); + + $bulkRun = BulkOperationRun::query() + ->where('resource', 'restore_run') + ->where('action', 'delete') + ->latest('id') + ->first(); + + expect($bulkRun)->not->toBeNull(); + expect($bulkRun->skipped)->toBeGreaterThanOrEqual(1); +}); diff --git a/tests/Feature/BulkDeleteRestoreRunsTest.php b/tests/Feature/BulkDeleteRestoreRunsTest.php new file mode 100644 index 0000000..1711219 --- /dev/null +++ b/tests/Feature/BulkDeleteRestoreRunsTest.php @@ -0,0 +1,50 @@ +create(); + $user = User::factory()->create(); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $runs = collect(range(1, 5))->map(function () use ($tenant, $backupSet) { + return RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'completed', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + }); + + Livewire::actingAs($user) + ->test(RestoreRunResource\Pages\ListRestoreRuns::class) + ->callTableBulkAction('bulk_delete', $runs) + ->assertHasNoTableBulkActionErrors(); + + $runs->each(fn (RestoreRun $run) => expect(RestoreRun::withTrashed()->find($run->id)?->trashed())->toBeTrue()); + + $bulkRun = BulkOperationRun::query() + ->where('resource', 'restore_run') + ->where('action', 'delete') + ->latest('id') + ->first(); + + expect($bulkRun)->not->toBeNull(); + expect($bulkRun->status)->toBe('completed'); +}); diff --git a/tests/Feature/BulkForceDeleteRestoreRunsTest.php b/tests/Feature/BulkForceDeleteRestoreRunsTest.php new file mode 100644 index 0000000..3e3a583 --- /dev/null +++ b/tests/Feature/BulkForceDeleteRestoreRunsTest.php @@ -0,0 +1,57 @@ +create(); + $user = User::factory()->create(); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $runs = collect(range(1, 3))->map(function () use ($tenant, $backupSet) { + $run = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'completed', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + $run->delete(); + + return $run; + }); + + Livewire::actingAs($user) + ->test(RestoreRunResource\Pages\ListRestoreRuns::class) + ->filterTable(\Filament\Tables\Filters\TrashedFilter::class, false) + ->callTableBulkAction('bulk_force_delete', $runs, data: [ + 'confirmation' => 'DELETE', + ]) + ->assertHasNoTableBulkActionErrors(); + + $runs->each(fn (RestoreRun $run) => expect(RestoreRun::withTrashed()->find($run->id))->toBeNull()); + + $bulkRun = BulkOperationRun::query() + ->where('resource', 'restore_run') + ->where('action', 'force_delete') + ->latest('id') + ->first(); + + expect($bulkRun)->not->toBeNull(); + expect($bulkRun->status)->toBe('completed'); +}); diff --git a/tests/Feature/BulkRestoreRestoreRunsTest.php b/tests/Feature/BulkRestoreRestoreRunsTest.php new file mode 100644 index 0000000..58d696f --- /dev/null +++ b/tests/Feature/BulkRestoreRestoreRunsTest.php @@ -0,0 +1,57 @@ +create(); + $user = User::factory()->create(); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $run = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'completed', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + $run->delete(); + expect($run->trashed())->toBeTrue(); + + Livewire::actingAs($user) + ->test(RestoreRunResource\Pages\ListRestoreRuns::class) + ->filterTable(\Filament\Tables\Filters\TrashedFilter::class, false) + ->callTableBulkAction('bulk_restore', collect([$run])) + ->assertHasNoTableBulkActionErrors(); + + $bulkRun = BulkOperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('user_id', $user->id) + ->where('resource', 'restore_run') + ->where('action', 'restore') + ->latest('id') + ->first(); + + expect($bulkRun)->not->toBeNull(); + expect($bulkRun->succeeded)->toBe(1) + ->and($bulkRun->skipped)->toBe(0) + ->and($bulkRun->failed)->toBe(0); + + $run->refresh(); + expect($run->trashed())->toBeFalse(); +}); diff --git a/tests/Feature/RestoreRunArchiveGuardTest.php b/tests/Feature/RestoreRunArchiveGuardTest.php new file mode 100644 index 0000000..50c7c57 --- /dev/null +++ b/tests/Feature/RestoreRunArchiveGuardTest.php @@ -0,0 +1,37 @@ +create(); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Set RR', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $running = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'running', + 'is_dry_run' => true, + ]); + + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(ListRestoreRuns::class) + ->callTableAction('archive', $running); + + expect(RestoreRun::withTrashed()->find($running->id)?->trashed())->toBeFalse(); +}); diff --git a/tests/Unit/BulkRestoreRunDeleteJobTest.php b/tests/Unit/BulkRestoreRunDeleteJobTest.php new file mode 100644 index 0000000..fc3e6c6 --- /dev/null +++ b/tests/Unit/BulkRestoreRunDeleteJobTest.php @@ -0,0 +1,94 @@ +create(); + $user = User::factory()->create(); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $completed = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'completed', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + $failed = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'failed', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'restore_run', 'delete', [$completed->id, $failed->id], 2); + + $job = new BulkRestoreRunDeleteJob($run->id); + $job->handle($service); + + $run->refresh(); + expect($run->status)->toBe('completed') + ->and($run->processed_items)->toBe(2) + ->and($run->succeeded)->toBe(2) + ->and($run->failed)->toBe(0) + ->and($run->skipped)->toBe(0); + + expect(RestoreRun::withTrashed()->find($completed->id)?->trashed())->toBeTrue(); + expect(RestoreRun::withTrashed()->find($failed->id)?->trashed())->toBeTrue(); +}); + +test('job skips non-deletable restore runs and records skip reasons', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $running = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'running', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'restore_run', 'delete', [$running->id], 1); + + $job = new BulkRestoreRunDeleteJob($run->id); + $job->handle($service); + + $run->refresh(); + expect($run->status)->toBe('completed') + ->and($run->processed_items)->toBe(1) + ->and($run->succeeded)->toBe(0) + ->and($run->failed)->toBe(0) + ->and($run->skipped)->toBe(1); + + expect($run->failures[0]['type'] ?? null)->toBe('skipped'); + expect($run->failures[0]['reason'] ?? '')->toContain('Not deletable'); + + expect(RestoreRun::withTrashed()->find($running->id)?->trashed())->toBeFalse(); +}); diff --git a/tests/Unit/BulkRestoreRunRestoreJobTest.php b/tests/Unit/BulkRestoreRunRestoreJobTest.php new file mode 100644 index 0000000..3e53f64 --- /dev/null +++ b/tests/Unit/BulkRestoreRunRestoreJobTest.php @@ -0,0 +1,102 @@ +create(['is_current' => true]); + $user = User::factory()->create(); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $restoreRun = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'completed', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + $restoreRun->delete(); + expect($restoreRun->trashed())->toBeTrue(); + + $run = BulkOperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'resource' => 'restore_run', + 'action' => 'restore', + 'status' => 'pending', + 'total_items' => 1, + 'item_ids' => [$restoreRun->id], + 'failures' => [], + ]); + + (new BulkRestoreRunRestoreJob($run->id))->handle(app(BulkOperationService::class)); + + $restoreRun->refresh(); + expect($restoreRun->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 restore run restore skips active runs', function () { + $tenant = Tenant::factory()->create(['is_current' => true]); + $user = User::factory()->create(); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $restoreRun = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'completed', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + $run = BulkOperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'resource' => 'restore_run', + 'action' => 'restore', + 'status' => 'pending', + 'total_items' => 1, + 'item_ids' => [$restoreRun->id], + 'failures' => [], + ]); + + (new BulkRestoreRunRestoreJob($run->id))->handle(app(BulkOperationService::class)); + + $restoreRun->refresh(); + expect($restoreRun->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'); +}); diff --git a/tests/Unit/RestoreRunDeletableTest.php b/tests/Unit/RestoreRunDeletableTest.php new file mode 100644 index 0000000..0ea051c --- /dev/null +++ b/tests/Unit/RestoreRunDeletableTest.php @@ -0,0 +1,56 @@ +create(); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $statuses = [ + 'completed', + 'failed', + 'aborted', + 'completed_with_errors', + 'partial', + 'running', + 'pending', + ]; + + foreach ($statuses as $status) { + RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => $status, + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + } + + $deletableStatuses = RestoreRun::query() + ->deletable() + ->pluck('status') + ->unique() + ->sort() + ->values() + ->all(); + + expect($deletableStatuses)->toBe([ + 'aborted', + 'completed', + 'completed_with_errors', + 'failed', + 'partial', + ]); +});