for($tenant)->create([ 'status' => 'completed', 'item_count' => 2, 'metadata' => [], ]); $run = OperationRun::factory()->forTenant($tenant)->create([ 'type' => 'backup_schedule_run', 'status' => OperationRunStatus::Queued->value, 'outcome' => OperationRunOutcome::Pending->value, 'created_at' => now()->subMinutes(20), 'context' => [ 'backup_schedule_id' => (int) $schedule->getKey(), 'backup_set_id' => (int) $backupSet->getKey(), ], ]); $result = app(AdapterRunReconciler::class)->reconcile([ 'type' => 'backup_schedule_run', 'managed_environment_id' => (int) $tenant->getKey(), 'older_than_minutes' => 10, 'limit' => 10, 'dry_run' => false, ]); expect($result['candidates'] ?? null)->toBe(1) ->and($result['reconciled'] ?? null)->toBe(1); $run->refresh(); $backupSet->refresh(); expect($run->status)->toBe(OperationRunStatus::Completed->value) ->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value) ->and($run->reconciliationAdapter())->toBe('backup_schedule_execution') ->and($run->reconciledRelatedBackupSetId())->toBe((int) $backupSet->getKey()) ->and($run->summary_counts)->toMatchArray([ 'total' => 2, 'processed' => 2, 'succeeded' => 2, 'failed' => 0, 'created' => 1, 'updated' => 2, 'items' => 2, ]) ->and($backupSet->status)->toBe('completed'); expect(array_keys(OperationRunLinks::related($run->fresh(), $tenant))) ->toContain('Backup Schedules', 'Backup Sets', 'Backup Set'); }); it('marks backup schedule runs partially succeeded when the backup set is usable but incomplete in Spec362', function (): void { [, $tenant] = createUserWithTenant(role: 'owner'); $schedule = spec362BackupSchedule($tenant); $backupSet = BackupSet::factory()->for($tenant)->create([ 'status' => 'partial', 'item_count' => 2, 'metadata' => [ 'failures' => [ ['policy_id' => 'policy-1'], ], ], ]); $run = OperationRun::factory()->forTenant($tenant)->create([ 'type' => 'backup.schedule.execute', 'status' => OperationRunStatus::Queued->value, 'outcome' => OperationRunOutcome::Pending->value, 'created_at' => now()->subMinutes(20), 'context' => [ 'backup_schedule_id' => (int) $schedule->getKey(), 'backup_set_id' => (int) $backupSet->getKey(), ], ]); $change = app(AdapterRunReconciler::class)->reconcileOperationRun($run, false); expect($change['applied'] ?? null)->toBeTrue(); $run->refresh(); expect($run->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value) ->and($run->reconciliationDecision())->toBe('reconciled_partially_succeeded') ->and($run->summary_counts)->toMatchArray([ 'total' => 3, 'processed' => 3, 'succeeded' => 2, 'failed' => 1, 'created' => 1, 'updated' => 2, 'items' => 3, ]); }); it('marks backup schedule runs blocked when the schedule was archived before any current backup set existed in Spec362', function (): void { [, $tenant] = createUserWithTenant(role: 'owner'); $schedule = spec362BackupSchedule($tenant); $schedule->delete(); $run = OperationRun::factory()->forTenant($tenant)->create([ 'type' => 'backup.schedule.execute', 'status' => OperationRunStatus::Queued->value, 'outcome' => OperationRunOutcome::Pending->value, 'created_at' => now()->subMinutes(20), 'context' => [ 'backup_schedule_id' => (int) $schedule->getKey(), ], ]); $change = app(AdapterRunReconciler::class)->reconcileOperationRun($run, false); expect($change['applied'] ?? null)->toBeTrue(); $run->refresh(); expect($run->outcome)->toBe(OperationRunOutcome::Blocked->value) ->and($run->reconciliationDecision())->toBe('blocked') ->and((string) data_get($run->failure_summary, '0.message'))->toContain('archived'); }); it('fails closed when a backup set crosses the queued tenant scope in Spec362', function (): void { [, $tenant] = createUserWithTenant(role: 'owner'); $foreignTenant = ManagedEnvironment::factory()->create(); $schedule = spec362BackupSchedule($tenant); $foreignBackupSet = BackupSet::factory()->for($foreignTenant)->create(); $run = OperationRun::factory()->forTenant($tenant)->create([ 'type' => 'backup.schedule.execute', 'status' => OperationRunStatus::Queued->value, 'outcome' => OperationRunOutcome::Pending->value, 'created_at' => now()->subMinutes(20), 'context' => [ 'backup_schedule_id' => (int) $schedule->getKey(), 'backup_set_id' => (int) $foreignBackupSet->getKey(), ], ]); $change = app(AdapterRunReconciler::class)->reconcileOperationRun($run, true); expect($change['applied'] ?? null)->toBeFalse() ->and($change['decision'] ?? null)->toBe('not_reconciled') ->and((string) ($change['reason_message'] ?? ''))->toContain('queued backup schedule scope safely'); $run->refresh(); expect($run->status)->toBe(OperationRunStatus::Queued->value) ->and($run->outcome)->toBe(OperationRunOutcome::Pending->value); }); it('keeps backup schedule runs active when no current backup set proof exists yet in Spec362', function (): void { [, $tenant] = createUserWithTenant(role: 'owner'); $schedule = spec362BackupSchedule($tenant); $run = OperationRun::factory()->forTenant($tenant)->create([ 'type' => 'backup.schedule.execute', 'status' => OperationRunStatus::Queued->value, 'outcome' => OperationRunOutcome::Pending->value, 'created_at' => now()->subMinutes(20), 'context' => [ 'backup_schedule_id' => (int) $schedule->getKey(), ], ]); $change = app(AdapterRunReconciler::class)->reconcileOperationRun($run, true); expect($change['applied'] ?? null)->toBeFalse() ->and($change['decision'] ?? null)->toBe('not_reconciled') ->and((string) ($change['reason_message'] ?? ''))->toContain('No current backup set proof'); $run->refresh(); expect($run->status)->toBe(OperationRunStatus::Queued->value) ->and($run->outcome)->toBe(OperationRunOutcome::Pending->value); }); function spec362BackupSchedule(ManagedEnvironment $tenant, array $overrides = []): BackupSchedule { return BackupSchedule::query()->create(array_merge([ 'managed_environment_id' => (int) $tenant->getKey(), 'name' => 'Spec362 Daily backup', 'is_enabled' => true, 'timezone' => 'UTC', 'frequency' => 'daily', 'time_of_day' => '10:00:00', 'days_of_week' => null, 'policy_types' => ['deviceConfiguration'], 'include_foundations' => true, 'retention_keep_last' => 30, 'last_run_at' => null, 'last_run_status' => null, 'next_run_at' => null, ], $overrides)); }