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
This commit is contained in:
parent
e7d2be16f2
commit
239a2e6af9
@ -5,6 +5,8 @@
|
|||||||
use App\Filament\Resources\BackupSetResource\Pages;
|
use App\Filament\Resources\BackupSetResource\Pages;
|
||||||
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
||||||
use App\Jobs\BulkBackupSetDeleteJob;
|
use App\Jobs\BulkBackupSetDeleteJob;
|
||||||
|
use App\Jobs\BulkBackupSetForceDeleteJob;
|
||||||
|
use App\Jobs\BulkBackupSetRestoreJob;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\BulkOperationService;
|
use App\Services\BulkOperationService;
|
||||||
@ -199,6 +201,105 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->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(),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
160
app/Jobs/BulkBackupSetForceDeleteJob.php
Normal file
160
app/Jobs/BulkBackupSetForceDeleteJob.php
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
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 BulkBackupSetForceDeleteJob 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 $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();
|
||||||
|
}
|
||||||
|
}
|
||||||
152
app/Jobs/BulkBackupSetRestoreJob.php
Normal file
152
app/Jobs/BulkBackupSetRestoreJob.php
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
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 BulkBackupSetRestoreJob 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 $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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -265,6 +265,14 @@ ### Implementation for Additional Resource
|
|||||||
- [ ] T084 Test delete with 15 backup sets (manual QA)
|
- [ ] T084 Test delete with 15 backup sets (manual QA)
|
||||||
- [x] T085 Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteBackupSetsTest.php`
|
- [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)
|
### Additional: Bulk Archive Backup Sets (FR-005.23)
|
||||||
|
|
||||||
- [ ] T085a Verify BackupSet has `archived_at` column; add migration if missing in database/migrations/
|
- [ ] T085a Verify BackupSet has `archived_at` column; add migration if missing in database/migrations/
|
||||||
|
|||||||
55
tests/Feature/BulkForceDeleteBackupSetsTest.php
Normal file
55
tests/Feature/BulkForceDeleteBackupSetsTest.php
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\BackupSetResource;
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('backup sets table bulk force delete permanently deletes archived sets and their items', function () {
|
||||||
|
$tenant = Tenant::factory()->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');
|
||||||
|
});
|
||||||
58
tests/Feature/BulkRestoreBackupSetsTest.php
Normal file
58
tests/Feature/BulkRestoreBackupSetsTest.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\BackupSetResource;
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('backup sets table bulk restore restores archived sets and their items', function () {
|
||||||
|
$tenant = Tenant::factory()->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');
|
||||||
|
});
|
||||||
107
tests/Unit/BulkBackupSetForceDeleteJobTest.php
Normal file
107
tests/Unit/BulkBackupSetForceDeleteJobTest.php
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\BulkBackupSetForceDeleteJob;
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
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 backup set force delete job permanently deletes archived sets and their items', 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' => 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');
|
||||||
|
});
|
||||||
105
tests/Unit/BulkBackupSetRestoreJobTest.php
Normal file
105
tests/Unit/BulkBackupSetRestoreJobTest.php
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\BulkBackupSetRestoreJob;
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
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 backup set restore job restores archived sets and their items', 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' => 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');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user