feat: bulk archive backup sets
- Add BulkBackupSetDeleteJob with tenant isolation and skip reasons - Add BackupSetResource bulk archive action with 10+ type-to-confirm - Add unit + feature tests and mark Phase 9 tasks complete
This commit is contained in:
parent
99f2a6309d
commit
e7d2be16f2
@ -4,20 +4,27 @@
|
||||
|
||||
use App\Filament\Resources\BackupSetResource\Pages;
|
||||
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
||||
use App\Jobs\BulkBackupSetDeleteJob;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\BackupService;
|
||||
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;
|
||||
use Filament\Resources\Resource;
|
||||
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 BackupSetResource extends Resource
|
||||
@ -135,7 +142,65 @@ public static function table(Table $table): Table
|
||||
}),
|
||||
])->icon('heroicon-o-ellipsis-vertical'),
|
||||
])
|
||||
->bulkActions([]);
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
BulkAction::make('bulk_delete')
|
||||
->label('Archive Backup Sets')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->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 backup sets (soft delete). Backup sets referenced by restore runs 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', 'delete', $ids, $count);
|
||||
|
||||
if ($count >= 10) {
|
||||
Notification::make()
|
||||
->title('Bulk archive started')
|
||||
->body("Archiving {$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();
|
||||
|
||||
BulkBackupSetDeleteJob::dispatch($run->id);
|
||||
} else {
|
||||
BulkBackupSetDeleteJob::dispatchSync($run->id);
|
||||
}
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
|
||||
158
app/Jobs/BulkBackupSetDeleteJob.php
Normal file
158
app/Jobs/BulkBackupSetDeleteJob.php
Normal file
@ -0,0 +1,158 @@
|
||||
<?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 BulkBackupSetDeleteJob 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 Archive 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, 'Already archived');
|
||||
$skipped++;
|
||||
$skipReasons['Already archived'] = ($skipReasons['Already 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->delete();
|
||||
$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 Archive 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 = "Archived {$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 Archive Completed')
|
||||
->body($message)
|
||||
->icon('heroicon-o-check-circle')
|
||||
->success()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
}
|
||||
@ -253,17 +253,17 @@ ## Phase 9: Additional Resource - Bulk Delete Backup Sets (Priority: P2)
|
||||
|
||||
### Tests for Additional Resource
|
||||
|
||||
- [ ] T078 [P] Write unit test for BulkBackupSetDeleteJob in tests/Unit/BulkBackupSetDeleteJobTest.php
|
||||
- [ ] T079 [P] Write feature test for bulk delete backup sets in tests/Feature/BulkDeleteBackupSetsTest.php
|
||||
- [x] T078 [P] Write unit test for BulkBackupSetDeleteJob in tests/Unit/BulkBackupSetDeleteJobTest.php
|
||||
- [x] T079 [P] Write feature test for bulk delete backup sets in tests/Feature/BulkDeleteBackupSetsTest.php
|
||||
|
||||
### Implementation for Additional Resource
|
||||
|
||||
- [ ] T080 [P] Create BulkBackupSetDeleteJob in app/Jobs/BulkBackupSetDeleteJob.php
|
||||
- [ ] T081 Add bulk delete action to BackupSetResource in app/Filament/Resources/BackupSetResource.php
|
||||
- [ ] T082 Verify cascade-delete logic for backup items (should be automatic via foreign key)
|
||||
- [ ] T083 Add type-to-confirm for ≥10 sets
|
||||
- [x] T080 [P] Create BulkBackupSetDeleteJob in app/Jobs/BulkBackupSetDeleteJob.php
|
||||
- [x] T081 Add bulk delete action to BackupSetResource in app/Filament/Resources/BackupSetResource.php
|
||||
- [x] T082 Verify cascade-delete logic for backup items (should be automatic via foreign key)
|
||||
- [x] T083 Add type-to-confirm for ≥10 sets
|
||||
- [ ] T084 Test delete with 15 backup sets (manual QA)
|
||||
- [ ] 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`
|
||||
|
||||
### Additional: Bulk Archive Backup Sets (FR-005.23)
|
||||
|
||||
|
||||
81
tests/Feature/BulkDeleteBackupSetsTest.php
Normal file
81
tests/Feature/BulkDeleteBackupSetsTest.php
Normal file
@ -0,0 +1,81 @@
|
||||
<?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 archive creates a run and archives selected sets', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$sets = collect(range(1, 3))->map(function (int $i) use ($tenant) {
|
||||
return BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup '.$i,
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
});
|
||||
|
||||
$sets->each(function (BackupSet $set) use ($tenant) {
|
||||
BackupItem::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $set->id,
|
||||
'policy_id' => null,
|
||||
'policy_identifier' => 'policy-'.$set->id,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'platform' => 'windows10',
|
||||
'payload' => ['id' => 'policy-'.$set->id],
|
||||
'metadata' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(BackupSetResource\Pages\ListBackupSets::class)
|
||||
->callTableBulkAction('bulk_delete', $sets)
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
|
||||
$sets->each(fn (BackupSet $set) => expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeTrue());
|
||||
|
||||
$bulkRun = BulkOperationRun::query()
|
||||
->where('resource', 'backup_set')
|
||||
->where('action', 'delete')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($bulkRun)->not->toBeNull();
|
||||
expect($bulkRun->status)->toBe('completed');
|
||||
});
|
||||
|
||||
test('backup sets table bulk archive requires type-to-confirm for 10+ sets', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$sets = collect(range(1, 10))->map(function (int $i) use ($tenant) {
|
||||
return BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup '.$i,
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
]);
|
||||
});
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(BackupSetResource\Pages\ListBackupSets::class)
|
||||
->callTableBulkAction('bulk_delete', $sets)
|
||||
->assertHasTableBulkActionErrors();
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(BackupSetResource\Pages\ListBackupSets::class)
|
||||
->callTableBulkAction('bulk_delete', $sets, data: [
|
||||
'confirmation' => 'DELETE',
|
||||
])
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
});
|
||||
91
tests/Unit/BulkBackupSetDeleteJobTest.php
Normal file
91
tests/Unit/BulkBackupSetDeleteJobTest.php
Normal file
@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\BulkBackupSetDeleteJob;
|
||||
use App\Models\BackupItem;
|
||||
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('bulk backup set delete job archives sets and cascades to backup items', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$set = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 2,
|
||||
]);
|
||||
|
||||
$items = collect(range(1, 2))->map(function (int $i) use ($tenant, $set) {
|
||||
return BackupItem::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $set->id,
|
||||
'policy_id' => null,
|
||||
'policy_identifier' => 'policy-'.$i,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'platform' => 'windows10',
|
||||
'payload' => ['id' => 'policy-'.$i],
|
||||
'metadata' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'backup_set', 'delete', [$set->id], 1);
|
||||
|
||||
(new BulkBackupSetDeleteJob($run->id))->handle($service);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed')
|
||||
->and($run->processed_items)->toBe(1)
|
||||
->and($run->succeeded)->toBe(1)
|
||||
->and($run->failed)->toBe(0)
|
||||
->and($run->skipped)->toBe(0);
|
||||
|
||||
expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeTrue();
|
||||
|
||||
$items->each(function (BackupItem $item) {
|
||||
expect(BackupItem::withTrashed()->find($item->id)?->trashed())->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
test('bulk backup set delete job skips sets referenced by restore runs', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$set = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
]);
|
||||
|
||||
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 = $service->createRun($tenant, $user, 'backup_set', 'delete', [$set->id], 1);
|
||||
|
||||
(new BulkBackupSetDeleteJob($run->id))->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(collect($run->failures)->pluck('reason')->join(' '))->toContain('restore runs');
|
||||
expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeFalse();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user