makeService(); $this->assertSame([], $service->listAssignableRoleIdsForActor(0)); $this->assertSame([], $service->listAssignableRoleIdsForActor(-5)); } public function testListReturnsAllActiveRolesWhenActorHasAssignAllPermission(): void { $permissionService = $this->createMock(PermissionService::class); $permissionService->expects($this->once()) ->method('userHas') ->with(42, PermissionService::ROLES_ASSIGN_ALL) ->willReturn(true); $roleRepository = $this->createMock(RoleRepositoryInterface::class); $roleRepository->expects($this->once()) ->method('listActiveIds') ->willReturn([1, 2, 3, 4]); $service = $this->makeService( permissionService: $permissionService, roleRepository: $roleRepository, ); $this->assertSame([1, 2, 3, 4], $service->listAssignableRoleIdsForActor(42)); } public function testListReturnsEmptyWhenActorHasNoRoles(): void { $permissionService = $this->createMock(PermissionService::class); $permissionService->method('userHas')->willReturn(false); $userRoleRepository = $this->createMock(UserRoleRepositoryInterface::class); $userRoleRepository->expects($this->once()) ->method('listRoleIdsByUserId') ->with(42) ->willReturn([]); $service = $this->makeService( permissionService: $permissionService, userRoleRepository: $userRoleRepository, ); $this->assertSame([], $service->listAssignableRoleIdsForActor(42)); } public function testListReturnsUnionOfAssignableRoleIdsFromActorRoles(): void { $permissionService = $this->createMock(PermissionService::class); $permissionService->method('userHas')->willReturn(false); $userRoleRepository = $this->createMock(UserRoleRepositoryInterface::class); $userRoleRepository->method('listRoleIdsByUserId')->willReturn([10, 11]); $assignableRepo = $this->createMock(RoleAssignableRoleRepositoryInterface::class); $assignableRepo->expects($this->once()) ->method('listAssignableRoleIdsByRoleIds') ->with([10, 11]) ->willReturn([20, 21, 22]); $service = $this->makeService( permissionService: $permissionService, userRoleRepository: $userRoleRepository, assignableRoleRepository: $assignableRepo, ); $this->assertSame([20, 21, 22], $service->listAssignableRoleIdsForActor(42)); } public function testComputeEffectiveKeepsFrozenRolesAndAppliesSubmittedAssignableRoles(): void { $permissionService = $this->createMock(PermissionService::class); $permissionService->method('userHas')->willReturn(false); $userRoleRepository = $this->createMock(UserRoleRepositoryInterface::class); $userRoleRepository->method('listRoleIdsByUserId')->willReturn([10]); $assignableRepo = $this->createMock(RoleAssignableRoleRepositoryInterface::class); $assignableRepo->method('listAssignableRoleIdsByRoleIds')->willReturn([20, 21]); $service = $this->makeService( permissionService: $permissionService, userRoleRepository: $userRoleRepository, assignableRoleRepository: $assignableRepo, ); // currentIds contains a non-assignable role 99 (frozen) plus assignable 20 (touchable). // submittedIds drops 20, adds 21, attempts to add 99 again and a forbidden 55. $result = $service->computeEffectiveRoleIds( actorUserId: 42, submittedIds: [21, 99, 55], currentIds: [99, 20], ); // Frozen: 99. Touched: 21. 55 is not assignable so dropped. 20 removed because actor chose not to keep it. $this->assertSame([99, 21], $result); } public function testComputeEffectiveDropsNonAssignableSubmittedIds(): void { $permissionService = $this->createMock(PermissionService::class); $permissionService->method('userHas')->willReturn(true); $roleRepository = $this->createMock(RoleRepositoryInterface::class); $roleRepository->method('listActiveIds')->willReturn([1, 2, 3]); $service = $this->makeService( permissionService: $permissionService, roleRepository: $roleRepository, ); $result = $service->computeEffectiveRoleIds(42, [1, 999], []); $this->assertSame([1], $result); } public function testReplaceAssignableRolesRecordsAuditOnChange(): void { $assignableRepo = $this->createMock(RoleAssignableRoleRepositoryInterface::class); $assignableRepo->method('listAssignableRoleIdsByRoleId') ->willReturnOnConsecutiveCalls([1, 2], [1, 2, 3]); $assignableRepo->expects($this->once()) ->method('replaceForRole') ->with(10, [1, 2, 3]) ->willReturn(true); $audit = $this->createMock(AuditRecorderInterface::class); $audit->expects($this->once()) ->method('record') ->with( 'admin.roles.assignable_roles.update', 'success', $this->callback(static function (array $metadata): bool { return ($metadata['actor_user_id'] ?? null) === 42 && ($metadata['target_id'] ?? null) === 10 && ($metadata['before']['assignable_role_ids'] ?? null) === [1, 2] && ($metadata['after']['assignable_role_ids'] ?? null) === [1, 2, 3]; }) ); $service = $this->makeService( assignableRoleRepository: $assignableRepo, systemAuditService: $audit, ); $this->assertTrue($service->replaceAssignableRoles(10, [1, 2, 3], 42)); } public function testReplaceAssignableRolesSkipsAuditWhenNothingChanged(): void { $assignableRepo = $this->createMock(RoleAssignableRoleRepositoryInterface::class); $assignableRepo->method('listAssignableRoleIdsByRoleId') ->willReturnOnConsecutiveCalls([1, 2], [1, 2]); $assignableRepo->method('replaceForRole')->willReturn(true); $audit = $this->createMock(AuditRecorderInterface::class); $audit->expects($this->never())->method('record'); $service = $this->makeService( assignableRoleRepository: $assignableRepo, systemAuditService: $audit, ); $this->assertTrue($service->replaceAssignableRoles(10, [1, 2], 42)); } public function testReplaceAssignableRolesNormalizesNonPositiveActorIdInAudit(): void { $assignableRepo = $this->createMock(RoleAssignableRoleRepositoryInterface::class); $assignableRepo->method('listAssignableRoleIdsByRoleId') ->willReturnOnConsecutiveCalls([], [5]); $assignableRepo->method('replaceForRole')->willReturn(true); $audit = $this->createMock(AuditRecorderInterface::class); $audit->expects($this->once()) ->method('record') ->with( 'admin.roles.assignable_roles.update', 'success', $this->callback(static fn (array $m): bool => array_key_exists('actor_user_id', $m) && $m['actor_user_id'] === null) ); $service = $this->makeService( assignableRoleRepository: $assignableRepo, systemAuditService: $audit, ); $service->replaceAssignableRoles(10, [5], 0); } private function makeService( ?RoleAssignableRoleRepositoryInterface $assignableRoleRepository = null, ?UserRoleRepositoryInterface $userRoleRepository = null, ?PermissionService $permissionService = null, ?RoleRepositoryInterface $roleRepository = null, ?AuditRecorderInterface $systemAuditService = null, ): AssignableRoleService { return new AssignableRoleService( $assignableRoleRepository ?? $this->createMock(RoleAssignableRoleRepositoryInterface::class), $userRoleRepository ?? $this->createMock(UserRoleRepositoryInterface::class), $permissionService ?? $this->createMock(PermissionService::class), $roleRepository ?? $this->createMock(RoleRepositoryInterface::class), $systemAuditService ?? $this->createMock(AuditRecorderInterface::class), ); } }