diff --git a/app/Services/Inventory/DependencyExtractionService.php b/app/Services/Inventory/DependencyExtractionService.php index b197564..46e7119 100644 --- a/app/Services/Inventory/DependencyExtractionService.php +++ b/app/Services/Inventory/DependencyExtractionService.php @@ -25,6 +25,17 @@ public function extractForPolicyData(InventoryItem $item, array $policyData): ar $edges = $edges->merge($this->extractAssignments($item, $policyData, $warnings)); $edges = $edges->merge($this->extractScopedBy($item, $policyData)); + $edges = $edges + ->unique(fn (array $e) => implode('|', [ + (string) ($e['tenant_id'] ?? ''), + (string) ($e['source_type'] ?? ''), + (string) ($e['source_id'] ?? ''), + (string) ($e['target_type'] ?? ''), + (string) ($e['target_id'] ?? ''), + (string) ($e['relationship_type'] ?? ''), + ])) + ->values(); + // Enforce max 50 outbound edges by priority: assigned_to > scoped_by > others $priorities = [ RelationshipType::AssignedToInclude->value => 1, diff --git a/tests/Unit/DependencyExtractionServiceTest.php b/tests/Unit/DependencyExtractionServiceTest.php index f0f83de..fc9173e 100644 --- a/tests/Unit/DependencyExtractionServiceTest.php +++ b/tests/Unit/DependencyExtractionServiceTest.php @@ -80,3 +80,35 @@ && ($context['policy_id'] ?? null) === $item->external_id) ->once(); }); + +it('deduplicates edges before upsert to avoid conflict errors', function () { + $tenant = \App\Models\Tenant::factory()->create(); + + $item = \App\Models\InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'policy_type' => 'settingsCatalogPolicy', + 'external_id' => 'pol-dup-1', + ]); + + $svc = app(\App\Services\Inventory\DependencyExtractionService::class); + + $policyData = [ + 'id' => 'pol-dup-1', + 'assignments' => [ + ['target' => ['groupId' => 'group-1', '@odata.type' => '#microsoft.graph.groupAssignmentTarget']], + ['target' => ['groupId' => 'group-1', '@odata.type' => '#microsoft.graph.groupAssignmentTarget']], + ], + 'roleScopeTagIds' => ['0', '0'], + ]; + + $warnings = $svc->extractForPolicyData($item, $policyData); + expect($warnings)->toBeArray()->toBeEmpty(); + + $edges = \App\Models\InventoryLink::query() + ->where('tenant_id', $tenant->getKey()) + ->where('source_id', 'pol-dup-1') + ->get(); + + expect($edges->where('relationship_type', 'assigned_to_include'))->toHaveCount(1); + expect($edges->where('relationship_type', 'scoped_by'))->toHaveCount(1); +});