forTenant($tenant)->create([ 'type' => 'inventory_sync', 'status' => OperationRunStatus::Queued->value, 'outcome' => OperationRunOutcome::Pending->value, 'created_at' => now()->subMinutes(20), 'context' => [ 'policy_types' => ['conditionalAccessPolicy', 'deviceConfiguration'], 'include_foundations' => false, 'inventory' => [ 'coverage' => InventoryCoverage::buildPayload([ 'conditionalAccessPolicy' => 'succeeded', 'deviceConfiguration' => 'succeeded', ], []), ], ], ]); InventoryItem::factory()->create([ 'managed_environment_id' => (int) $tenant->getKey(), 'policy_type' => 'conditionalAccessPolicy', 'last_seen_operation_run_id' => (int) $run->getKey(), ]); InventoryItem::factory()->create([ 'managed_environment_id' => (int) $tenant->getKey(), 'policy_type' => 'deviceConfiguration', 'last_seen_operation_run_id' => (int) $run->getKey(), ]); $result = app(AdapterRunReconciler::class)->reconcile([ 'type' => 'inventory_sync', '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(); expect($run->status)->toBe(OperationRunStatus::Completed->value) ->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value) ->and($run->reconciliationDecision())->toBe('reconciled_succeeded') ->and($run->reconciliationAdapter())->toBe('inventory_sync') ->and($run->summary_counts)->toMatchArray([ 'total' => 2, 'processed' => 2, 'succeeded' => 2, 'failed' => 0, 'skipped' => 0, 'items' => 2, 'updated' => 2, ]) ->and(InventoryItem::query()->where('last_seen_operation_run_id', (int) $run->getKey())->count())->toBe(2); expect(array_keys(OperationRunLinks::related($run->fresh(), $tenant))) ->toContain('Inventory', 'Inventory Coverage'); }); it('marks inventory sync runs partially succeeded when usable coverage exists alongside blocked proof in Spec362', function (): void { [, $tenant] = createUserWithTenant(role: 'owner'); $run = OperationRun::factory()->forTenant($tenant)->create([ 'type' => 'inventory.sync', 'status' => OperationRunStatus::Queued->value, 'outcome' => OperationRunOutcome::Pending->value, 'created_at' => now()->subMinutes(20), 'context' => [ 'policy_types' => ['conditionalAccessPolicy', 'deviceConfiguration'], 'include_foundations' => false, 'inventory' => [ 'coverage' => InventoryCoverage::buildPayload([ 'conditionalAccessPolicy' => 'succeeded', 'deviceConfiguration' => 'skipped', ], []), ], 'result' => [ 'error_codes' => ['concurrency_limit_tenant'], ], ], ]); $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' => 2, 'succeeded' => 1, 'failed' => 0, 'skipped' => 1, ]); }); it('marks inventory sync runs blocked when no canonical coverage was written and lock contention is the only proof in Spec362', function (): void { [, $tenant] = createUserWithTenant(role: 'owner'); $run = OperationRun::factory()->forTenant($tenant)->create([ 'type' => 'inventory.sync', 'status' => OperationRunStatus::Queued->value, 'outcome' => OperationRunOutcome::Pending->value, 'created_at' => now()->subMinutes(20), 'context' => [ 'policy_types' => ['deviceConfiguration'], 'include_foundations' => false, 'result' => [ 'error_codes' => ['lock_contended'], ], ], ]); $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(data_get($run->failure_summary, '0.message'))->toContain('same selection lock'); }); it('fails closed when inventory proof leaks outside the queued tenant scope in Spec362', function (): void { [, $tenant] = createUserWithTenant(role: 'owner'); $foreignTenant = ManagedEnvironment::factory()->create([ 'workspace_id' => (int) $tenant->workspace_id, ]); $run = OperationRun::factory()->forTenant($tenant)->create([ 'type' => 'inventory.sync', 'status' => OperationRunStatus::Queued->value, 'outcome' => OperationRunOutcome::Pending->value, 'created_at' => now()->subMinutes(20), 'context' => [ 'policy_types' => ['deviceConfiguration'], 'include_foundations' => false, 'inventory' => [ 'coverage' => InventoryCoverage::buildPayload([ 'deviceConfiguration' => 'succeeded', ], []), ], ], ]); InventoryItem::factory()->create([ 'managed_environment_id' => (int) $tenant->getKey(), 'policy_type' => 'deviceConfiguration', 'last_seen_operation_run_id' => (int) $run->getKey(), ]); InventoryItem::factory()->create([ 'managed_environment_id' => (int) $foreignTenant->getKey(), 'policy_type' => 'deviceConfiguration', 'last_seen_operation_run_id' => (int) $run->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('outside the run scope'); $run->refresh(); expect($run->status)->toBe(OperationRunStatus::Queued->value) ->and($run->outcome)->toBe(OperationRunOutcome::Pending->value); }); it('keeps selected-family reconciliation idempotent once a run is finalized in Spec362', function (): void { [, $tenant] = createUserWithTenant(role: 'owner'); $run = OperationRun::factory()->forTenant($tenant)->create([ 'type' => 'inventory_sync', 'status' => OperationRunStatus::Queued->value, 'outcome' => OperationRunOutcome::Pending->value, 'created_at' => now()->subMinutes(20), 'context' => [ 'policy_types' => ['deviceConfiguration'], 'include_foundations' => false, 'inventory' => [ 'coverage' => InventoryCoverage::buildPayload([ 'deviceConfiguration' => 'succeeded', ], []), ], ], ]); InventoryItem::factory()->create([ 'managed_environment_id' => (int) $tenant->getKey(), 'policy_type' => 'deviceConfiguration', 'last_seen_operation_run_id' => (int) $run->getKey(), ]); $reconciler = app(AdapterRunReconciler::class); $first = $reconciler->reconcile([ 'type' => 'inventory_sync', 'managed_environment_id' => (int) $tenant->getKey(), 'older_than_minutes' => 10, 'limit' => 10, 'dry_run' => false, ]); $second = $reconciler->reconcile([ 'type' => 'inventory_sync', 'managed_environment_id' => (int) $tenant->getKey(), 'older_than_minutes' => 10, 'limit' => 10, 'dry_run' => false, ]); $run->refresh(); expect($first['reconciled'] ?? null)->toBe(1) ->and($second['candidates'] ?? null)->toBe(0) ->and($second['reconciled'] ?? null)->toBe(0) ->and(data_get($run->context, 'reconciliation.adapter'))->toBe('inventory_sync') ->and(data_get($run->context, 'reconciliation.previous_status'))->toBe(OperationRunStatus::Queued->value) ->and(data_get($run->context, 'reconciliation.reason_code'))->toBe('run.adapter_out_of_sync'); });