feat/005-bulk-operations #5

Merged
ahmido merged 25 commits from feat/005-bulk-operations into dev 2025-12-25 13:32:37 +00:00
14 changed files with 1191 additions and 13 deletions
Showing only changes of commit e603a1245e - Show all commits

View File

@ -3,14 +3,20 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Resources\RestoreRunResource\Pages; 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\BackupItem;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\BulkOperationService;
use App\Services\Intune\RestoreService; use App\Services\Intune\RestoreService;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Forms; use Filament\Forms;
use Filament\Infolists; use Filament\Infolists;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@ -18,7 +24,10 @@
use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use UnitEnum; use UnitEnum;
class RestoreRunResource extends Resource class RestoreRunResource extends Resource
@ -105,7 +114,11 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('requested_by')->label('Requested by'), Tables\Columns\TextColumn::make('requested_by')->label('Requested by'),
]) ])
->filters([ ->filters([
Tables\Filters\TrashedFilter::make(), TrashedFilter::make()
->label('Archived')
->placeholder('Active')
->trueLabel('All')
->falseLabel('Archived'),
]) ])
->actions([ ->actions([
Actions\ViewAction::make(), Actions\ViewAction::make(),
@ -117,6 +130,16 @@ public static function table(Table $table): Table
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (RestoreRun $record) => ! $record->trashed()) ->visible(fn (RestoreRun $record) => ! $record->trashed())
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { ->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(); $record->delete();
if ($record->tenant) { if ($record->tenant) {
@ -162,7 +185,158 @@ public static function table(Table $table): Table
}), }),
])->icon('heroicon-o-ellipsis-vertical'), ])->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 public static function infolist(Schema $schema): Schema

View File

@ -0,0 +1,177 @@
<?php
namespace App\Jobs;
use App\Models\BulkOperationRun;
use App\Models\RestoreRun;
use App\Services\BulkOperationService;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
class BulkRestoreRunDeleteJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public int $bulkRunId,
) {}
public function handle(BulkOperationService $service): void
{
$run = BulkOperationRun::with('user')->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;
}
}
}

View File

@ -0,0 +1,150 @@
<?php
namespace App\Jobs;
use App\Models\BulkOperationRun;
use App\Models\RestoreRun;
use App\Services\BulkOperationService;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
class BulkRestoreRunForceDeleteJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public int $bulkRunId,
) {}
public function handle(BulkOperationService $service): void
{
$run = BulkOperationRun::with('user')->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();
}
}

View File

@ -0,0 +1,150 @@
<?php
namespace App\Jobs;
use App\Models\BulkOperationRun;
use App\Models\RestoreRun;
use App\Services\BulkOperationService;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
class BulkRestoreRunRestoreJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public int $bulkRunId,
) {}
public function handle(BulkOperationService $service): void
{
$run = BulkOperationRun::with('user')->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();
}
}

View File

@ -36,6 +36,11 @@ public function backupSet(): BelongsTo
public function scopeDeletable($query) 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);
} }
} }

View File

@ -218,20 +218,28 @@ ## Phase 8: User Story 4 - Bulk Delete Restore Runs (Priority: P2)
### Tests for User Story 4 ### Tests for User Story 4
- [ ] T067 [P] [US4] Write unit test for deletable() scope in tests/Unit/RestoreRunDeletableTest.php - [x] 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 - [x] 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 - [x] 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] T070 [P] [US4] Write test for mixed statuses (skip running) in tests/Feature/BulkDeleteMixedStatusTest.php
### Implementation for User Story 4 ### Implementation for User Story 4
- [ ] T071 [P] [US4] Create BulkRestoreRunDeleteJob in app/Jobs/BulkRestoreRunDeleteJob.php - [x] T071 [P] [US4] Create BulkRestoreRunDeleteJob in app/Jobs/BulkRestoreRunDeleteJob.php
- [ ] T072 [US4] Add bulk delete action to RestoreRunResource in app/Filament/Resources/RestoreRunResource.php - [x] T072 [US4] Add bulk delete action to RestoreRunResource in app/Filament/Resources/RestoreRunResource.php
- [ ] T073 [US4] Filter by deletable() scope (completed, failed, aborted only) - [x] T073 [US4] Filter by deletable() scope (completed, failed, aborted only)
- [ ] T074 [US4] Skip running restore runs with warning - [x] T074 [US4] Skip running restore runs with warning
- [ ] T075 [US4] Add type-to-confirm for ≥20 runs - [x] T075 [US4] Add type-to-confirm for ≥20 runs
- [ ] T076 [US4] Test delete with 20 completed + 5 running (manual QA, verify 5 skipped) - [ ] 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 **Checkpoint**: Restore runs bulk delete working, running runs protected, skip warnings shown

View File

@ -0,0 +1,61 @@
<?php
use App\Filament\Resources\RestoreRunResource;
use App\Models\BackupSet;
use App\Models\BulkOperationRun;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('bulk delete restore runs skips running items', function () {
$tenant = Tenant::factory()->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);
});

View File

@ -0,0 +1,50 @@
<?php
use App\Filament\Resources\RestoreRunResource;
use App\Models\BackupSet;
use App\Models\BulkOperationRun;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('bulk delete restore runs soft deletes selected runs', function () {
$tenant = Tenant::factory()->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');
});

View File

@ -0,0 +1,57 @@
<?php
use App\Filament\Resources\RestoreRunResource;
use App\Models\BackupSet;
use App\Models\BulkOperationRun;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('bulk force delete restore runs permanently deletes archived runs', function () {
$tenant = Tenant::factory()->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');
});

View File

@ -0,0 +1,57 @@
<?php
use App\Filament\Resources\RestoreRunResource;
use App\Models\BackupSet;
use App\Models\BulkOperationRun;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('restore runs table bulk restore creates a run and restores archived records', function () {
$tenant = Tenant::factory()->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();
});

View File

@ -0,0 +1,37 @@
<?php
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
use App\Models\BackupSet;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('restore run archive action does not archive non-deletable runs', function () {
$tenant = Tenant::factory()->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();
});

View File

@ -0,0 +1,94 @@
<?php
use App\Jobs\BulkRestoreRunDeleteJob;
use App\Models\BackupSet;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
test('job soft deletes deletable restore runs', function () {
$tenant = Tenant::factory()->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();
});

View File

@ -0,0 +1,102 @@
<?php
use App\Jobs\BulkRestoreRunRestoreJob;
use App\Models\BackupSet;
use App\Models\BulkOperationRun;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
test('bulk restore run restore restores archived 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',
]);
$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');
});

View File

@ -0,0 +1,56 @@
<?php
use App\Models\BackupSet;
use App\Models\RestoreRun;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
test('deletable scope includes only finished statuses', function () {
$tenant = Tenant::factory()->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',
]);
});