create([ 'tenant_id' => $tenant->getKey(), ]); $restoreRun = RestoreRun::factory()->create([ 'tenant_id' => $tenant->getKey(), 'backup_set_id' => $backupSet->getKey(), 'status' => 'completed', 'is_dry_run' => false, 'started_at' => CarbonImmutable::now()->subMinutes(20), 'completed_at' => CarbonImmutable::now()->subMinutes(10), 'metadata' => [ 'total' => 10, 'succeeded' => 8, 'failed' => 1, 'skipped' => 1, ], // Intentionally malformed outcomes to ensure reconciler never explodes. 'results' => [ 'items' => [ '123' => [ 'assignment_outcomes' => ['not-an-array'], ], ], ], ]); $opRun = OperationRun::factory()->create([ 'tenant_id' => $tenant->getKey(), 'user_id' => $user->getKey(), 'initiator_name' => $user->name, 'type' => 'restore.execute', 'status' => 'queued', 'outcome' => 'pending', 'started_at' => null, 'completed_at' => null, 'created_at' => CarbonImmutable::now()->subMinutes(120), 'context' => [ 'restore_run_id' => $restoreRun->getKey(), ], 'summary_counts' => [], ]); $result = app(AdapterRunReconciler::class)->reconcile([ 'type' => 'restore.execute', 'tenant_id' => (int) $tenant->getKey(), 'older_than_minutes' => 10, 'limit' => 10, 'dry_run' => false, ]); expect($result['reconciled'] ?? null)->toBe(1); $opRun->refresh(); expect($opRun->status)->toBe('completed'); expect($opRun->outcome)->toBe('succeeded'); expect($opRun->summary_counts['total'] ?? null)->toBe(10); expect($opRun->summary_counts['succeeded'] ?? null)->toBe(8); expect($opRun->summary_counts['failed'] ?? null)->toBe(1); expect($opRun->summary_counts['skipped'] ?? null)->toBe(1); $context = is_array($opRun->context) ? $opRun->context : []; expect($context['reconciliation']['reason'] ?? null)->toBe('adapter_out_of_sync'); expect($context['reconciliation']['reconciled_at'] ?? null)->toBeString(); expect($opRun->started_at)->not->toBeNull(); expect($opRun->completed_at)->not->toBeNull(); })->group('ops-ux'); it('is idempotent (second run performs no work)', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); $backupSet = BackupSet::factory()->create([ 'tenant_id' => $tenant->getKey(), ]); $restoreRun = RestoreRun::factory()->create([ 'tenant_id' => $tenant->getKey(), 'backup_set_id' => $backupSet->getKey(), 'status' => 'completed', 'is_dry_run' => false, 'metadata' => [ 'total' => 1, 'succeeded' => 1, ], ]); OperationRun::factory()->create([ 'tenant_id' => $tenant->getKey(), 'user_id' => $user->getKey(), 'initiator_name' => $user->name, 'type' => 'restore.execute', 'status' => 'queued', 'outcome' => 'pending', 'created_at' => CarbonImmutable::now()->subMinutes(120), 'context' => [ 'restore_run_id' => $restoreRun->getKey(), ], ]); $reconciler = app(AdapterRunReconciler::class); $first = $reconciler->reconcile([ 'tenant_id' => (int) $tenant->getKey(), 'older_than_minutes' => 10, 'limit' => 10, 'dry_run' => false, ]); expect($first['reconciled'] ?? null)->toBe(1); $second = $reconciler->reconcile([ 'tenant_id' => (int) $tenant->getKey(), 'older_than_minutes' => 10, 'limit' => 10, 'dry_run' => false, ]); expect($second['candidates'] ?? null)->toBe(0); expect($second['reconciled'] ?? null)->toBe(0); })->group('ops-ux'); it('does not persist non-whitelisted summary_counts keys during reconciliation', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); $backupSet = BackupSet::factory()->create([ 'tenant_id' => $tenant->getKey(), ]); $restoreRun = RestoreRun::factory()->create([ 'tenant_id' => $tenant->getKey(), 'backup_set_id' => $backupSet->getKey(), 'status' => 'completed', 'is_dry_run' => false, 'metadata' => [ 'total' => 2, 'succeeded' => 2, 'secrets' => 999, 'assignments_success' => 123, ], ]); $opRun = OperationRun::factory()->create([ 'tenant_id' => $tenant->getKey(), 'user_id' => $user->getKey(), 'initiator_name' => $user->name, 'type' => 'restore.execute', 'status' => 'queued', 'outcome' => 'pending', 'created_at' => CarbonImmutable::now()->subMinutes(120), 'context' => [ 'restore_run_id' => $restoreRun->getKey(), ], ]); app(AdapterRunReconciler::class)->reconcile([ 'tenant_id' => (int) $tenant->getKey(), 'older_than_minutes' => 10, 'limit' => 10, 'dry_run' => false, ]); $opRun->refresh(); expect($opRun->summary_counts['total'] ?? null)->toBe(2); expect($opRun->summary_counts['succeeded'] ?? null)->toBe(2); expect($opRun->summary_counts)->not->toHaveKey('secrets'); expect($opRun->summary_counts)->not->toHaveKey('assignments_success'); })->group('ops-ux');