'pol-1', 'displayName' => 'Test Policy', 'assignments' => [ ['target' => ['groupId' => 'group-1']], ['target' => ['groupId' => 'group-2']], ], 'roleScopeTagIds' => ['scope-tag-1', 'scope-tag-2'], ], ], 200); } public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse { return new GraphResponse(true, [], 200); } public function getOrganization(array $options = []): GraphResponse { return new GraphResponse(true, [], 200); } public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse { return new GraphResponse(true, [], 200); } public function getServicePrincipalPermissions(array $options = []): GraphResponse { return new GraphResponse(true, [], 200); } public function request(string $method, string $path, array $options = []): GraphResponse { return new GraphResponse(true, [], 200); } } it('extracts edges during inventory sync and marks missing appropriately', function () { $tenant = Tenant::factory()->create(); $this->app->bind(GraphClientInterface::class, fn () => new FakeGraphClientForDeps); $svc = app(InventorySyncService::class); $run = $svc->syncNow($tenant, [ 'policy_types' => ['deviceConfiguration'], 'categories' => [], 'include_foundations' => false, 'include_dependencies' => true, ]); expect($run->status)->toBe('success'); $edges = InventoryLink::query()->where('tenant_id', $tenant->getKey())->get(); // 2 assigned_to + 2 scoped_by = 4 expect($edges->count())->toBe(4); }); it('respects 50-edge limit for outbound extraction', function () { $tenant = Tenant::factory()->create(); // Fake client returning 60 group assignments $this->app->bind(GraphClientInterface::class, function () { return new class implements GraphClientInterface { public function listPolicies(string $policyType, array $options = []): GraphResponse { $assignments = []; for ($i = 1; $i <= 60; $i++) { $assignments[] = ['target' => ['groupId' => 'g-'.$i]]; } return new GraphResponse(true, [[ 'id' => 'pol-2', 'displayName' => 'Big Assignments', 'assignments' => $assignments, ]]); } public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse { return new GraphResponse(true); } public function getOrganization(array $options = []): GraphResponse { return new GraphResponse(true); } public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse { return new GraphResponse(true); } public function getServicePrincipalPermissions(array $options = []): GraphResponse { return new GraphResponse(true); } public function request(string $method, string $path, array $options = []): GraphResponse { return new GraphResponse(true); } }; }); $svc = app(InventorySyncService::class); $svc->syncNow($tenant, [ 'policy_types' => ['deviceConfiguration'], 'categories' => [], 'include_foundations' => false, 'include_dependencies' => true, ]); $count = InventoryLink::query()->where('tenant_id', $tenant->getKey())->count(); expect($count)->toBe(50); }); it('persists unsupported reference warnings on the sync run record', function () { $tenant = Tenant::factory()->create(); $this->app->bind(GraphClientInterface::class, function () { return new class implements GraphClientInterface { public function listPolicies(string $policyType, array $options = []): GraphResponse { return new GraphResponse(true, [[ 'id' => 'pol-warn-1', 'displayName' => 'Unsupported Assignment Target', 'assignments' => [ ['target' => ['filterId' => 'filter-only-no-group']], ], ]]); } public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse { return new GraphResponse(true); } public function getOrganization(array $options = []): GraphResponse { return new GraphResponse(true); } public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse { return new GraphResponse(true); } public function getServicePrincipalPermissions(array $options = []): GraphResponse { return new GraphResponse(true); } public function request(string $method, string $path, array $options = []): GraphResponse { return new GraphResponse(true); } }; }); $svc = app(InventorySyncService::class); $run = $svc->syncNow($tenant, [ 'policy_types' => ['deviceConfiguration'], 'categories' => [], 'include_foundations' => false, 'include_dependencies' => true, ]); $warnings = $run->error_context['warnings'] ?? null; expect($warnings)->toBeArray()->toHaveCount(1); expect($warnings[0]['type'] ?? null)->toBe('unsupported_reference'); expect(InventoryLink::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0); }); it('orders inbound/outbound edges by created_at desc and applies limit-only behavior', function () { $tenant = Tenant::factory()->create(); /** @var InventoryItem $item */ $item = InventoryItem::factory()->create([ 'tenant_id' => $tenant->getKey(), ]); $svc = app(DependencyQueryService::class); InventoryLink::factory()->create([ 'tenant_id' => $tenant->getKey(), 'source_type' => 'inventory_item', 'source_id' => $item->external_id, 'target_type' => 'foundation_object', 'target_id' => '11111111-1111-1111-1111-111111111111', 'relationship_type' => 'assigned_to', 'created_at' => now()->subMinutes(10), 'updated_at' => now()->subMinutes(10), ]); InventoryLink::factory()->create([ 'tenant_id' => $tenant->getKey(), 'source_type' => 'inventory_item', 'source_id' => $item->external_id, 'target_type' => 'foundation_object', 'target_id' => '22222222-2222-2222-2222-222222222222', 'relationship_type' => 'assigned_to', 'created_at' => now()->subMinutes(5), 'updated_at' => now()->subMinutes(5), ]); InventoryLink::factory()->create([ 'tenant_id' => $tenant->getKey(), 'source_type' => 'inventory_item', 'source_id' => $item->external_id, 'target_type' => 'foundation_object', 'target_id' => '33333333-3333-3333-3333-333333333333', 'relationship_type' => 'assigned_to', 'created_at' => now()->subMinutes(1), 'updated_at' => now()->subMinutes(1), ]); $outbound = $svc->getOutboundEdges($item, null, 2); expect($outbound)->toHaveCount(2); expect($outbound[0]->target_id)->toBe('33333333-3333-3333-3333-333333333333'); expect($outbound[1]->target_id)->toBe('22222222-2222-2222-2222-222222222222'); expect($outbound[0]->created_at->greaterThan($outbound[1]->created_at))->toBeTrue(); InventoryLink::factory()->create([ 'tenant_id' => $tenant->getKey(), 'source_type' => 'inventory_item', 'source_id' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'target_type' => 'inventory_item', 'target_id' => $item->external_id, 'relationship_type' => 'depends_on', 'created_at' => now()->subMinutes(9), 'updated_at' => now()->subMinutes(9), ]); InventoryLink::factory()->create([ 'tenant_id' => $tenant->getKey(), 'source_type' => 'inventory_item', 'source_id' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'target_type' => 'inventory_item', 'target_id' => $item->external_id, 'relationship_type' => 'depends_on', 'created_at' => now()->subMinutes(2), 'updated_at' => now()->subMinutes(2), ]); InventoryLink::factory()->create([ 'tenant_id' => $tenant->getKey(), 'source_type' => 'inventory_item', 'source_id' => 'cccccccc-cccc-cccc-cccc-cccccccccccc', 'target_type' => 'inventory_item', 'target_id' => $item->external_id, 'relationship_type' => 'depends_on', 'created_at' => now()->subMinutes(1), 'updated_at' => now()->subMinutes(1), ]); $inbound = $svc->getInboundEdges($item, null, 2); expect($inbound)->toHaveCount(2); expect($inbound[0]->source_id)->toBe('cccccccc-cccc-cccc-cccc-cccccccccccc'); expect($inbound[1]->source_id)->toBe('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'); expect($inbound[0]->created_at->greaterThan($inbound[1]->created_at))->toBeTrue(); }); it('hydrates settings catalog assignments and extracts include/exclude/filter edges', function () { $tenant = Tenant::factory()->create(); $this->app->bind(GraphClientInterface::class, function () { return new class implements GraphClientInterface { public function listPolicies(string $policyType, array $options = []): GraphResponse { return new GraphResponse(true, [[ 'id' => 'sc-1', 'name' => 'Settings Catalog Policy', 'roleScopeTagIds' => ['scope-tag-1'], // assignments present but empty (must still be hydrated via /assignments) 'assignments' => [], ]], 200); } public function request(string $method, string $path, array $options = []): GraphResponse { if ($method === 'GET' && $path === '/deviceManagement/configurationPolicies/sc-1/assignments') { return new GraphResponse(true, [ 'value' => [ [ 'target' => [ '@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-inc-1', ], ], [ 'target' => [ '@odata.type' => '#microsoft.graph.exclusionGroupAssignmentTarget', 'groupId' => 'group-exc-1', ], ], [ 'target' => [ '@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-inc-2', ], 'deviceAndAppManagementAssignmentFilterId' => 'filter-1', 'deviceAndAppManagementAssignmentFilterType' => 'include', ], ], ], 200); } return new GraphResponse(true, [], 200); } public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse { return new GraphResponse(true, [], 200); } public function getOrganization(array $options = []): GraphResponse { return new GraphResponse(true, [], 200); } public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse { return new GraphResponse(true, [], 200); } public function getServicePrincipalPermissions(array $options = []): GraphResponse { return new GraphResponse(true, [], 200); } }; }); $svc = app(InventorySyncService::class); $run = $svc->syncNow($tenant, [ 'policy_types' => ['settingsCatalogPolicy'], 'categories' => [], 'include_foundations' => false, 'include_dependencies' => true, ]); expect($run->status)->toBe('success'); $edges = InventoryLink::query()->where('tenant_id', $tenant->getKey())->get(); expect($edges->where('relationship_type', 'scoped_by'))->toHaveCount(1); expect($edges->where('relationship_type', 'assigned_to_include'))->toHaveCount(2); expect($edges->where('relationship_type', 'assigned_to_exclude'))->toHaveCount(1); expect($edges->where('relationship_type', 'uses_assignment_filter'))->toHaveCount(1); $filterEdge = $edges->firstWhere('relationship_type', 'uses_assignment_filter'); expect($filterEdge)->not->toBeNull(); expect($filterEdge->metadata['foundation_type'] ?? null)->toBe('assignment_filter'); expect($filterEdge->metadata['filter_mode'] ?? null)->toBe('include'); });