create(array_merge([ 'tenant_id' => $tenant->id, 'name' => 'Nightly lifecycle', 'is_enabled' => true, 'timezone' => 'UTC', 'frequency' => 'daily', 'time_of_day' => '01:00:00', 'days_of_week' => null, 'policy_types' => ['deviceConfiguration'], 'include_foundations' => true, 'retention_keep_last' => 30, ], $attributes)); } it('archives schedules, hides them from default list, and shows them in archived filter', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); $schedule = makeBackupScheduleForLifecycle($tenant, ['name' => 'Archive me']); $this->actingAs($user); Filament::setTenant($tenant, true); Livewire::test(ListBackupSchedules::class) ->callTableAction('archive', $schedule) ->assertHasNoTableActionErrors(); $schedule->refresh(); expect($schedule->trashed())->toBeTrue(); $this->assertDatabaseHas('audit_logs', [ 'tenant_id' => $tenant->id, 'action' => 'backup_schedule.archived', 'resource_type' => 'backup_schedule', 'resource_id' => (string) $schedule->id, ]); Livewire::test(ListBackupSchedules::class) ->assertCanNotSeeTableRecords([$schedule]); Livewire::test(ListBackupSchedules::class) ->filterTable(TrashedFilter::class, false) ->assertCanSeeTableRecords([BackupSchedule::withTrashed()->findOrFail($schedule->id)]); }); it('restores archived schedules without changing enabled state', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); $schedule = makeBackupScheduleForLifecycle($tenant, [ 'name' => 'Restore me', 'is_enabled' => false, ]); $schedule->delete(); $this->actingAs($user); Filament::setTenant($tenant, true); Livewire::test(ListBackupSchedules::class) ->filterTable(TrashedFilter::class, false) ->assertTableActionExists('restore', function ($action): bool { return $action->isConfirmationRequired() === false; }, BackupSchedule::withTrashed()->findOrFail($schedule->id)) ->callTableAction('restore', BackupSchedule::withTrashed()->findOrFail($schedule->id)) ->assertHasNoTableActionErrors(); $schedule->refresh(); expect($schedule->trashed())->toBeFalse(); expect((bool) $schedule->is_enabled)->toBeFalse(); $this->assertDatabaseHas('audit_logs', [ 'tenant_id' => $tenant->id, 'action' => 'backup_schedule.restored', 'resource_type' => 'backup_schedule', 'resource_id' => (string) $schedule->id, ]); }); it('allows force delete only to users with tenant delete capability', function () { [$manager, $tenant] = createUserWithTenant(role: 'manager'); $schedule = makeBackupScheduleForLifecycle($tenant, ['name' => 'Protected force delete']); $schedule->delete(); $this->actingAs($manager); Filament::setTenant($tenant, true); expect(function () use ($manager, $schedule): void { Gate::forUser($manager)->authorize('forceDelete', $schedule); })->toThrow(AuthorizationException::class); Livewire::test(ListBackupSchedules::class) ->filterTable(TrashedFilter::class, false) ->assertTableActionDisabled('forceDelete', BackupSchedule::withTrashed()->findOrFail($schedule->id)); expect(BackupSchedule::withTrashed()->whereKey($schedule->id)->exists())->toBeTrue(); }); it('blocks force delete when historical runs exist', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); $schedule = makeBackupScheduleForLifecycle($tenant, ['name' => 'Blocked force delete']); $schedule->delete(); OperationRun::factory()->create([ 'tenant_id' => $tenant->id, 'workspace_id' => $tenant->workspace_id, 'type' => 'backup_schedule_run', 'status' => 'completed', 'outcome' => 'succeeded', 'summary_counts' => [], 'failure_summary' => [], 'context' => ['backup_schedule_id' => (int) $schedule->id], ]); $this->actingAs($user); Filament::setTenant($tenant, true); Livewire::test(ListBackupSchedules::class) ->filterTable(TrashedFilter::class, false) ->callTableAction('forceDelete', BackupSchedule::withTrashed()->findOrFail($schedule->id)) ->assertHasNoTableActionErrors(); expect(BackupSchedule::withTrashed()->whereKey($schedule->id)->exists())->toBeTrue(); $this->assertDatabaseMissing('audit_logs', [ 'tenant_id' => $tenant->id, 'action' => 'backup_schedule.force_deleted', 'resource_id' => (string) $schedule->id, ]); }); it('force deletes archived schedules when no historical runs exist', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); $schedule = makeBackupScheduleForLifecycle($tenant, ['name' => 'Delete me forever']); $schedule->delete(); $this->actingAs($user); Filament::setTenant($tenant, true); Livewire::test(ListBackupSchedules::class) ->filterTable(TrashedFilter::class, false) ->callTableAction('forceDelete', BackupSchedule::withTrashed()->findOrFail($schedule->id)) ->assertHasNoTableActionErrors(); expect(BackupSchedule::withTrashed()->whereKey($schedule->id)->exists())->toBeFalse(); $this->assertDatabaseHas('audit_logs', [ 'tenant_id' => $tenant->id, 'action' => 'backup_schedule.force_deleted', 'resource_type' => 'backup_schedule', 'resource_id' => (string) $schedule->id, ]); }); it('allows editing archived schedules for authorized members', function () { [$user, $tenant] = createUserWithTenant(role: 'manager'); $schedule = makeBackupScheduleForLifecycle($tenant, ['name' => 'Archived editable']); $schedule->delete(); $this->actingAs($user); Filament::setTenant($tenant, true); Livewire::test(EditBackupSchedule::class, ['record' => $schedule->getRouteKey()]) ->fillForm([ 'name' => 'Archived edited', ]) ->call('save') ->assertHasNoFormErrors(); $schedule->refresh(); expect($schedule->name)->toBe('Archived edited'); expect($schedule->trashed())->toBeTrue(); }); it('enforces state-idempotent lifecycle actions in the table surface', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); $active = makeBackupScheduleForLifecycle($tenant, ['name' => 'Active state']); $archived = makeBackupScheduleForLifecycle($tenant, ['name' => 'Archived state']); $archived->delete(); $this->actingAs($user); Filament::setTenant($tenant, true); Livewire::test(ListBackupSchedules::class) ->assertTableActionHidden('restore', $active) ->assertTableActionHidden('forceDelete', $active) ->assertTableActionHidden('archive', BackupSchedule::withTrashed()->findOrFail($archived->id)); expect((bool) $active->fresh()->trashed())->toBeFalse(); expect((bool) BackupSchedule::withTrashed()->findOrFail($archived->id)->trashed())->toBeTrue(); }); it('clamps resolved workspace retention default to workspace retention floor', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); WorkspaceSetting::query()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'domain' => 'backup', 'key' => 'retention_keep_last_default', 'value' => 2, 'updated_by_user_id' => (int) $user->getKey(), ]); WorkspaceSetting::query()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'domain' => 'backup', 'key' => 'retention_min_floor', 'value' => 4, 'updated_by_user_id' => (int) $user->getKey(), ]); $schedule = makeBackupScheduleForLifecycle($tenant, [ 'name' => 'Floor clamp default', 'retention_keep_last' => null, ]); $sets = collect(range(1, 6))->map(function (int $index) use ($tenant): BackupSet { return BackupSet::query()->create([ 'tenant_id' => (int) $tenant->getKey(), 'name' => 'Clamp Default '.$index, 'status' => 'completed', 'item_count' => 0, 'completed_at' => now()->subMinutes(12 - $index), ]); }); $completedAt = now('UTC')->startOfMinute()->subMinutes(8); foreach ($sets as $set) { OperationRun::query()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'tenant_id' => (int) $tenant->getKey(), 'user_id' => null, 'initiator_name' => 'System', 'type' => 'backup_schedule_run', 'status' => 'completed', 'outcome' => 'succeeded', 'run_identity_hash' => hash('sha256', 'lifecycle-floor-default:'.$schedule->id.':'.$set->id), 'summary_counts' => [], 'failure_summary' => [], 'context' => [ 'backup_schedule_id' => (int) $schedule->id, 'backup_set_id' => (int) $set->id, ], 'started_at' => $completedAt, 'completed_at' => $completedAt, ]); $completedAt = $completedAt->addMinute(); } ApplyBackupScheduleRetentionJob::dispatchSync((int) $schedule->id); $keptIds = BackupSet::query() ->where('tenant_id', (int) $tenant->getKey()) ->whereNull('deleted_at') ->orderBy('id') ->pluck('id') ->all(); expect($keptIds)->toHaveCount(4); }); it('clamps schedule retention override when override is below workspace retention floor', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); WorkspaceSetting::query()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'domain' => 'backup', 'key' => 'retention_min_floor', 'value' => 3, 'updated_by_user_id' => (int) $user->getKey(), ]); $schedule = makeBackupScheduleForLifecycle($tenant, [ 'name' => 'Floor clamp override', 'retention_keep_last' => 1, ]); $sets = collect(range(1, 5))->map(function (int $index) use ($tenant): BackupSet { return BackupSet::query()->create([ 'tenant_id' => (int) $tenant->getKey(), 'name' => 'Clamp Override '.$index, 'status' => 'completed', 'item_count' => 0, 'completed_at' => now()->subMinutes(10 - $index), ]); }); $completedAt = now('UTC')->startOfMinute()->subMinutes(6); foreach ($sets as $set) { OperationRun::query()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'tenant_id' => (int) $tenant->getKey(), 'user_id' => null, 'initiator_name' => 'System', 'type' => 'backup_schedule_run', 'status' => 'completed', 'outcome' => 'succeeded', 'run_identity_hash' => hash('sha256', 'lifecycle-floor-override:'.$schedule->id.':'.$set->id), 'summary_counts' => [], 'failure_summary' => [], 'context' => [ 'backup_schedule_id' => (int) $schedule->id, 'backup_set_id' => (int) $set->id, ], 'started_at' => $completedAt, 'completed_at' => $completedAt, ]); $completedAt = $completedAt->addMinute(); } ApplyBackupScheduleRetentionJob::dispatchSync((int) $schedule->id); $keptIds = BackupSet::query() ->where('tenant_id', (int) $tenant->getKey()) ->whereNull('deleted_at') ->orderBy('id') ->pluck('id') ->all(); expect($keptIds)->toHaveCount(3); });