map(fn (array $row): mixed => $row['type'] ?? null) ->filter(fn (mixed $type): bool => is_string($type) && $type !== '') ->values() ->all(); if ((bool) ($selection['include_foundations'] ?? false)) { return array_values(array_unique(array_merge($policyTypes, $foundationTypes))); } return array_values(array_diff($policyTypes, $foundationTypes)); } it('executes a pending inventory sync run and updates bulk progress + initiator attribution', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); $this->mock(GraphClientInterface::class, function (MockInterface $mock) { $mock->shouldReceive('listPolicies')->never(); }); $sync = app(InventorySyncService::class); $selectionPayload = $sync->defaultSelectionPayload(); $computed = $sync->normalizeAndHashSelection($selectionPayload); $policyTypes = $computed['selection']['policy_types']; $attemptedTypes = attemptedInventoryPolicyTypes($computed['selection']); $mockSync = \Mockery::mock(InventorySyncService::class); $mockSync ->shouldReceive('executeSelection') ->once() ->andReturnUsing(function (OperationRun $operationRun, $tenant, array $selectionPayload, ?callable $onPolicyTypeProcessed) use ($computed): array { foreach ($computed['selection']['policy_types'] as $policyType) { $onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, true, null, 1); } return [ 'status' => 'success', 'had_errors' => false, 'error_codes' => [], 'error_context' => [], 'errors_count' => 0, 'items_observed_count' => count($computed['selection']['policy_types']), 'items_upserted_count' => count($computed['selection']['policy_types']), 'skipped_policy_types' => [], 'processed_policy_types' => $computed['selection']['policy_types'], 'failed_policy_types' => [], 'selection_hash' => $computed['selection_hash'], ]; }); /** @var OperationRunService $opService */ $opService = app(OperationRunService::class); $opRun = $opService->ensureRun( tenant: $tenant, type: 'inventory_sync', inputs: $computed['selection'], initiator: $user, ); $job = new RunInventorySyncJob( tenantId: (int) $tenant->getKey(), userId: (int) $user->getKey(), operationRun: $opRun, ); $job->handle($mockSync, app(AuditLogger::class), $opService); $opRun->refresh(); expect($opRun->status)->toBe('completed'); expect($opRun->outcome)->toBe('succeeded'); $context = is_array($opRun->context) ? $opRun->context : []; expect($context)->toHaveKey('result'); expect($context['result']['had_errors'] ?? null)->toBeFalse(); expect($context['inventory']['coverage']['policy_types'][array_values($policyTypes)[0]]['item_count'] ?? null)->toBe(1); $counts = is_array($opRun->summary_counts) ? $opRun->summary_counts : []; expect((int) ($counts['total'] ?? 0))->toBe(count($attemptedTypes)); expect((int) ($counts['processed'] ?? 0))->toBe(count($attemptedTypes)); expect((int) ($counts['succeeded'] ?? 0))->toBe(count($attemptedTypes)); expect((int) ($counts['failed'] ?? 0))->toBe(0); expect($user->notifications()->count())->toBe(1); $this->assertDatabaseHas('notifications', [ 'notifiable_id' => $user->getKey(), 'notifiable_type' => $user->getMorphClass(), 'type' => OperationRunCompleted::class, 'data->title' => 'Inventory sync completed successfully', ]); }); it('maps skipped inventory sync runs to bulk progress as skipped with reason', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); $sync = app(InventorySyncService::class); $selectionPayload = $sync->defaultSelectionPayload(); $computed = $sync->normalizeAndHashSelection($selectionPayload); $policyTypes = $computed['selection']['policy_types']; $attemptedTypes = attemptedInventoryPolicyTypes($computed['selection']); /** @var OperationRunService $opService */ $opService = app(OperationRunService::class); $opRun = $opService->ensureRun( tenant: $tenant, type: 'inventory_sync', inputs: $computed['selection'], initiator: $user, ); $mockSync = \Mockery::mock(InventorySyncService::class); $mockSync ->shouldReceive('executeSelection') ->once() ->andReturn([ 'status' => 'skipped', 'had_errors' => true, 'error_codes' => ['locked'], 'error_context' => [], 'errors_count' => 0, 'items_observed_count' => 0, 'items_upserted_count' => 0, 'skipped_policy_types' => $computed['selection']['policy_types'], 'processed_policy_types' => [], 'failed_policy_types' => [], 'selection_hash' => $computed['selection_hash'], ]); $job = new RunInventorySyncJob( tenantId: (int) $tenant->getKey(), userId: (int) $user->getKey(), operationRun: $opRun, ); $job->handle($mockSync, app(AuditLogger::class), $opService); $opRun->refresh(); expect($opRun->status)->toBe('completed'); expect($opRun->outcome)->toBe('failed'); $context = is_array($opRun->context) ? $opRun->context : []; expect($context)->toHaveKey('result'); expect($context['result']['had_errors'] ?? null)->toBeTrue(); expect($context['inventory']['coverage']['policy_types'][array_values($policyTypes)[0]]['error_code'] ?? null)->toBe('locked'); $counts = is_array($opRun->summary_counts) ? $opRun->summary_counts : []; expect((int) ($counts['processed'] ?? 0))->toBe(count($attemptedTypes)); expect((int) ($counts['skipped'] ?? 0))->toBe(count($attemptedTypes)); expect((int) ($counts['succeeded'] ?? 0))->toBe(0); expect((int) ($counts['failed'] ?? 0))->toBe(0); $failures = is_array($opRun->failure_summary) ? $opRun->failure_summary : []; expect($failures)->toBeArray(); expect($failures[0]['code'] ?? null)->toBe('inventory.skipped'); expect($failures[0]['message'] ?? null)->toBe('locked'); expect($user->notifications()->count())->toBe(1); $this->assertDatabaseHas('notifications', [ 'notifiable_id' => $user->getKey(), 'notifiable_type' => $user->getMorphClass(), 'type' => OperationRunCompleted::class, 'data->title' => 'Inventory sync execution failed', ]); }); it('seeds and advances counted progress before inventory sync reaches a terminal outcome', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); $sync = app(InventorySyncService::class); $selectionPayload = $sync->defaultSelectionPayload(); $selectionPayload['include_foundations'] = false; $selectionPayload['policy_types'] = array_slice($selectionPayload['policy_types'], 0, 2); $computed = $sync->normalizeAndHashSelection($selectionPayload); $attemptedTypes = $computed['selection']['policy_types']; expect($attemptedTypes)->toHaveCount(2); /** @var OperationRunService $opService */ $opService = app(OperationRunService::class); $opRun = $opService->ensureRun( tenant: $tenant, type: 'inventory_sync', inputs: $computed['selection'], initiator: $user, ); $mockSync = \Mockery::mock(InventorySyncService::class); $mockSync ->shouldReceive('executeSelection') ->once() ->andReturnUsing(function (OperationRun $operationRun, $tenantArg, array $selection, ?callable $onPolicyTypeProcessed) use ($tenant, $attemptedTypes): array { expect($tenantArg->is($tenant))->toBeTrue(); expect($selection['policy_types'] ?? [])->toBe($attemptedTypes); $operationRun->refresh(); expect($operationRun->summary_counts ?? [])->toMatchArray([ 'total' => count($attemptedTypes), 'processed' => 0, 'succeeded' => 0, 'failed' => 0, 'skipped' => 0, ]); $onPolicyTypeProcessed && $onPolicyTypeProcessed($attemptedTypes[0], true, null, 3); $operationRun->refresh(); expect($operationRun->summary_counts ?? [])->toMatchArray([ 'total' => count($attemptedTypes), 'processed' => 1, 'succeeded' => 1, 'failed' => 0, 'skipped' => 0, ]); $onPolicyTypeProcessed && $onPolicyTypeProcessed($attemptedTypes[1], false, 'graph_forbidden', 0); $operationRun->refresh(); expect($operationRun->summary_counts ?? [])->toMatchArray([ 'total' => count($attemptedTypes), 'processed' => 2, 'succeeded' => 1, 'failed' => 1, 'skipped' => 0, ]); return [ 'status' => 'partial', 'had_errors' => true, 'error_codes' => ['graph_forbidden'], 'error_context' => [], 'errors_count' => 1, 'items_observed_count' => 3, 'items_upserted_count' => 3, 'skipped_policy_types' => [], 'processed_policy_types' => $attemptedTypes, 'failed_policy_types' => [$attemptedTypes[1]], 'selection_hash' => hash('sha256', implode('|', $attemptedTypes)), ]; }); $job = new RunInventorySyncJob( tenantId: (int) $tenant->getKey(), userId: (int) $user->getKey(), operationRun: $opRun, ); $job->handle($mockSync, app(AuditLogger::class), $opService); $opRun->refresh(); expect($opRun->outcome)->toBe('partially_succeeded'); expect($opRun->summary_counts ?? [])->toMatchArray([ 'total' => count($attemptedTypes), 'processed' => 2, 'succeeded' => 1, 'failed' => 1, ]); }); it('declares the inventory sync lifecycle contract explicitly', function (): void { $job = new RunInventorySyncJob( tenantId: 1, userId: 1, operationRun: OperationRun::factory()->make(), ); expect(class_uses_recursive($job))->toContain(BridgesFailedOperationRun::class) ->and($job->timeout)->toBe(240) ->and($job->failOnTimeout)->toBeTrue(); });