create([ 'tenant_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'user_id' => (int) $user->getKey(), 'type' => 'policy.sync', 'status' => OperationRunStatus::Queued->value, 'outcome' => OperationRunOutcome::Pending->value, 'started_at' => null, 'created_at' => now()->subMinutes(15), ]); $staleRunning = OperationRun::factory()->create([ 'tenant_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'user_id' => (int) $user->getKey(), 'type' => 'inventory_sync', 'status' => OperationRunStatus::Running->value, 'outcome' => OperationRunOutcome::Pending->value, 'started_at' => now()->subMinutes(30), 'created_at' => now()->subMinutes(30), ]); $freshRunning = OperationRun::factory()->create([ 'tenant_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'user_id' => (int) $user->getKey(), 'type' => 'inventory_sync', 'status' => OperationRunStatus::Running->value, 'outcome' => OperationRunOutcome::Pending->value, 'started_at' => now()->subMinutes(2), 'created_at' => now()->subMinutes(2), ]); $result = app(OperationLifecycleReconciler::class)->reconcile([ 'types' => ['policy.sync', 'inventory_sync'], 'tenant_ids' => [(int) $tenant->getKey()], 'dry_run' => false, ]); expect($result['reconciled'])->toBe(2) ->and($result['skipped'])->toBe(1); expect($staleQueued->fresh()->status)->toBe(OperationRunStatus::Completed->value) ->and($staleQueued->fresh()->outcome)->toBe(OperationRunOutcome::Failed->value) ->and(data_get($staleQueued->fresh()->context, 'reconciliation.reason_code'))->toBe('run.stale_queued'); expect($staleRunning->fresh()->status)->toBe(OperationRunStatus::Completed->value) ->and($staleRunning->fresh()->outcome)->toBe(OperationRunOutcome::Failed->value) ->and(data_get($staleRunning->fresh()->context, 'reconciliation.reason_code'))->toBe('run.stale_running'); expect($freshRunning->fresh()->status)->toBe(OperationRunStatus::Running->value) ->and($freshRunning->fresh()->outcome)->toBe(OperationRunOutcome::Pending->value); }); it('is idempotent when the reconciler is run repeatedly', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); $run = OperationRun::factory()->create([ 'tenant_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'user_id' => (int) $user->getKey(), 'type' => 'policy.sync', 'status' => OperationRunStatus::Queued->value, 'outcome' => OperationRunOutcome::Pending->value, 'created_at' => now()->subMinutes(20), ]); $reconciler = app(OperationLifecycleReconciler::class); $first = $reconciler->reconcile([ 'types' => ['policy.sync'], 'tenant_ids' => [(int) $tenant->getKey()], ]); $second = $reconciler->reconcile([ 'types' => ['policy.sync'], 'tenant_ids' => [(int) $tenant->getKey()], ]); expect($first['reconciled'])->toBe(1) ->and($second['reconciled'])->toBe(0) ->and($run->fresh()->status)->toBe(OperationRunStatus::Completed->value); });