newService(); $result = $service->buildAssignmentsForUser(0); $this->assertSame([], $result['tenants']); $this->assertSame([], $result['departments']); $this->assertSame([], $result['roles']); } public function testBuildAssignmentsForUserReturnsEmptyStructureWhenUserIdIsNegative(): void { $service = $this->newService(); $result = $service->buildAssignmentsForUser(-5); $this->assertSame([], $result['tenants']); $this->assertSame([], $result['departments']); $this->assertSame([], $result['roles']); } public function testBuildAssignmentsForUserReturnsTenantsDepartmentsAndRoles(): void { $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); $userTenantRepo->expects($this->once())->method('listTenantIdsByUserId')->with(10)->willReturn([1, 2]); $userDeptRepo = $this->createMock(UserDepartmentRepositoryInterface::class); $userDeptRepo->expects($this->once())->method('listDepartmentIdsByUserId')->with(10)->willReturn([100]); $userRoleRepo = $this->createMock(UserRoleRepositoryInterface::class); $userRoleRepo->expects($this->once())->method('listRoleIdsByUserId')->with(10)->willReturn([50]); $directoryGateway = $this->createMock(UserDirectoryGateway::class); $directoryGateway->expects($this->once())->method('listTenantsByIds')->with([1, 2])->willReturn([ ['id' => 1, 'uuid' => 'tenant-1', 'description' => 'Tenant 1', 'status' => 'active'], ['id' => 2, 'uuid' => 'tenant-2', 'description' => 'Tenant 2', 'status' => 'active'], ]); $directoryGateway->expects($this->once())->method('listDepartmentsByIds')->with([100], true)->willReturn([ ['id' => 100, 'uuid' => 'dept-100', 'description' => 'Dept 100', 'active' => 1, 'tenant_id' => 1], ]); $directoryGateway->expects($this->once())->method('listRolesByIds')->with([50])->willReturn([ ['id' => 50, 'uuid' => 'role-50', 'description' => 'Role 50', 'active' => 1], ]); $service = $this->newService( userTenantRepository: $userTenantRepo, userDepartmentRepository: $userDeptRepo, userRoleRepository: $userRoleRepo, directoryGateway: $directoryGateway ); $result = $service->buildAssignmentsForUser(10); $this->assertCount(2, $result['tenants']); $this->assertSame(1, $result['tenants'][0]['id']); $this->assertSame(2, $result['tenants'][1]['id']); $this->assertCount(1, $result['departments']); $this->assertSame(100, $result['departments'][0]['id']); $this->assertSame('tenant-1', $result['departments'][0]['tenant_uuid']); $this->assertCount(1, $result['roles']); $this->assertSame(50, $result['roles'][0]['id']); } // ── syncTenants ───────────────────────────────────────────────────── public function testSyncTenantsNormalizesAndCallsRepository(): void { $directoryGateway = $this->createMock(UserDirectoryGateway::class); $directoryGateway->expects($this->once())->method('listTenantIds')->willReturn([1, 2, 3]); $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); $userTenantRepo->expects($this->once()) ->method('replaceForUser') ->with(10, $this->callback(static fn (array $ids): bool => $ids === [1, 2])) ->willReturn(true); $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); $userWriteRepo->expects($this->once())->method('bumpAuthzVersion')->with(10); $service = $this->newService( userWriteRepository: $userWriteRepo, userTenantRepository: $userTenantRepo, directoryGateway: $directoryGateway ); $result = $service->syncTenants(10, [1, 2]); $this->assertTrue($result); } public function testSyncTenantsDoesNotBumpAuthzWhenFlagIsFalse(): void { $directoryGateway = $this->createMock(UserDirectoryGateway::class); $directoryGateway->method('listTenantIds')->willReturn([1]); $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); $userTenantRepo->method('replaceForUser')->willReturn(true); $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); $userWriteRepo->expects($this->never())->method('bumpAuthzVersion'); $service = $this->newService( userWriteRepository: $userWriteRepo, userTenantRepository: $userTenantRepo, directoryGateway: $directoryGateway ); $result = $service->syncTenants(10, [1], false); $this->assertTrue($result); } // ── syncRoles ─────────────────────────────────────────────────────── public function testSyncRolesDeduplicatesAndFiltersAgainstActiveRoles(): void { $directoryGateway = $this->createMock(UserDirectoryGateway::class); $directoryGateway->expects($this->once())->method('listActiveRoleIds')->willReturn([1, 2, 3]); $userRoleRepo = $this->createMock(UserRoleRepositoryInterface::class); $userRoleRepo->expects($this->once()) ->method('replaceForUser') ->with(10, $this->callback(static fn (array $ids): bool => $ids === [1, 3])) ->willReturn(true); $permissionService = $this->createMock(PermissionService::class); $permissionService->expects($this->once())->method('clearUserCache')->with(10); $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); $userWriteRepo->expects($this->once())->method('bumpAuthzVersion')->with(10); $service = $this->newService( userWriteRepository: $userWriteRepo, userRoleRepository: $userRoleRepo, directoryGateway: $directoryGateway, permissionService: $permissionService ); // Pass duplicates and an inactive role (99) $result = $service->syncRoles(10, [1, 3, 3, 99]); $this->assertTrue($result); } public function testSyncRolesWithActorEnforcesAssignableRoleFreeze(): void { $directoryGateway = $this->createMock(UserDirectoryGateway::class); $directoryGateway->method('listActiveRoleIds')->willReturn([1, 2, 3, 4]); $userRoleRepo = $this->createMock(UserRoleRepositoryInterface::class); $userRoleRepo->expects($this->once())->method('listRoleIdsByUserId')->with(10)->willReturn([2, 4]); $userRoleRepo->expects($this->once()) ->method('replaceForUser') ->with(10, [4, 1]) // frozen(4) + touched(1) ->willReturn(true); $assignableRoleService = $this->createMock(AssignableRoleService::class); $assignableRoleService->expects($this->once()) ->method('computeEffectiveRoleIds') ->with(99, [1, 3], [2, 4]) ->willReturn([4, 1]); // role 4 frozen, role 1 submitted & assignable $permissionService = $this->createMock(PermissionService::class); $permissionService->expects($this->once())->method('clearUserCache')->with(10); $service = $this->newService( userRoleRepository: $userRoleRepo, directoryGateway: $directoryGateway, permissionService: $permissionService, assignableRoleService: $assignableRoleService ); $result = $service->syncRoles(10, [1, 3], true, 99); $this->assertTrue($result); } public function testSyncRolesClearsPermissionCacheEvenOnFailure(): void { $directoryGateway = $this->createMock(UserDirectoryGateway::class); $directoryGateway->method('listActiveRoleIds')->willReturn([1]); $userRoleRepo = $this->createMock(UserRoleRepositoryInterface::class); $userRoleRepo->expects($this->once())->method('replaceForUser')->willReturn(false); $permissionService = $this->createMock(PermissionService::class); $permissionService->expects($this->once())->method('clearUserCache')->with(10); $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); $userWriteRepo->expects($this->never())->method('bumpAuthzVersion'); $service = $this->newService( userWriteRepository: $userWriteRepo, userRoleRepository: $userRoleRepo, directoryGateway: $directoryGateway, permissionService: $permissionService ); $result = $service->syncRoles(10, [1]); $this->assertFalse($result); } // ── syncDepartments ───────────────────────────────────────────────── public function testSyncDepartmentsValidatesDepartmentsBelongToUserTenants(): void { $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); $userTenantRepo->expects($this->once())->method('listTenantIdsByUserId')->with(10)->willReturn([1]); $directoryGateway = $this->createMock(UserDirectoryGateway::class); $directoryGateway->expects($this->once()) ->method('listActiveDepartmentIdsByTenantIds') ->with([1]) ->willReturn([100, 101]); $userDeptRepo = $this->createMock(UserDepartmentRepositoryInterface::class); $userDeptRepo->expects($this->once()) ->method('replaceForUser') ->with(10, [100]) // 200 filtered out because not in allowed list ->willReturn(true); $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); $userWriteRepo->expects($this->once())->method('bumpAuthzVersion')->with(10); $service = $this->newService( userWriteRepository: $userWriteRepo, userTenantRepository: $userTenantRepo, userDepartmentRepository: $userDeptRepo, directoryGateway: $directoryGateway ); $result = $service->syncDepartments(10, [100, 200]); $this->assertTrue($result); } public function testSyncDepartmentsRejectsAllWhenUserHasNoTenants(): void { $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); $userTenantRepo->expects($this->once())->method('listTenantIdsByUserId')->with(10)->willReturn([]); $directoryGateway = $this->createMock(UserDirectoryGateway::class); $directoryGateway->expects($this->never())->method('listActiveDepartmentIdsByTenantIds'); $userDeptRepo = $this->createMock(UserDepartmentRepositoryInterface::class); $userDeptRepo->expects($this->once()) ->method('replaceForUser') ->with(10, []) ->willReturn(true); $service = $this->newService( userTenantRepository: $userTenantRepo, userDepartmentRepository: $userDeptRepo, directoryGateway: $directoryGateway ); $result = $service->syncDepartments(10, [100]); $this->assertTrue($result); } public function testSyncDepartmentsRejectsAllWhenNoActiveDepartmentsInTenants(): void { $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); $userTenantRepo->method('listTenantIdsByUserId')->willReturn([1]); $directoryGateway = $this->createMock(UserDirectoryGateway::class); $directoryGateway->method('listActiveDepartmentIdsByTenantIds')->willReturn([]); $userDeptRepo = $this->createMock(UserDepartmentRepositoryInterface::class); $userDeptRepo->expects($this->once()) ->method('replaceForUser') ->with(10, []) ->willReturn(true); $service = $this->newService( userTenantRepository: $userTenantRepo, userDepartmentRepository: $userDeptRepo, directoryGateway: $directoryGateway ); $result = $service->syncDepartments(10, [100]); $this->assertTrue($result); } // ── syncAllAssignments ────────────────────────────────────────────── public function testSyncAllAssignmentsReturnsFalseWhenUserIdIsZero(): void { $service = $this->newService(); $this->assertFalse($service->syncAllAssignments(0, [], [], [])); } public function testSyncAllAssignmentsCommitsTransactionOnSuccess(): void { $directoryGateway = $this->createMock(UserDirectoryGateway::class); $directoryGateway->method('listTenantIds')->willReturn([1]); $directoryGateway->method('listActiveRoleIds')->willReturn([2]); $directoryGateway->method('listActiveDepartmentIdsByTenantIds')->willReturn([3]); $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); $userTenantRepo->method('replaceForUser')->willReturn(true); $userTenantRepo->method('listTenantIdsByUserId')->willReturn([1]); $userRoleRepo = $this->createMock(UserRoleRepositoryInterface::class); $userRoleRepo->method('replaceForUser')->willReturn(true); $userDeptRepo = $this->createMock(UserDepartmentRepositoryInterface::class); $userDeptRepo->method('replaceForUser')->willReturn(true); $permissionService = $this->createMock(PermissionService::class); $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); $userWriteRepo->expects($this->once())->method('bumpAuthzVersion')->with(10); $dbSession = $this->createMock(DatabaseSessionRepository::class); $dbSession->expects($this->once())->method('beginTransaction'); $dbSession->expects($this->once())->method('commitTransaction'); $dbSession->expects($this->never())->method('rollbackTransaction'); $service = $this->newService( userWriteRepository: $userWriteRepo, userTenantRepository: $userTenantRepo, userRoleRepository: $userRoleRepo, userDepartmentRepository: $userDeptRepo, directoryGateway: $directoryGateway, permissionService: $permissionService, databaseSessionRepository: $dbSession ); $result = $service->syncAllAssignments(10, [1], [2], [3]); $this->assertTrue($result); } public function testSyncAllAssignmentsRollsBackOnSyncFailure(): void { $directoryGateway = $this->createMock(UserDirectoryGateway::class); $directoryGateway->method('listTenantIds')->willReturn([1]); $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); $userTenantRepo->method('replaceForUser')->willReturn(false); // tenant sync fails $dbSession = $this->createMock(DatabaseSessionRepository::class); $dbSession->expects($this->once())->method('beginTransaction'); $dbSession->expects($this->never())->method('commitTransaction'); $dbSession->expects($this->once())->method('rollbackTransaction'); $service = $this->newService( userTenantRepository: $userTenantRepo, directoryGateway: $directoryGateway, databaseSessionRepository: $dbSession ); $result = $service->syncAllAssignments(10, [1], [2], [3]); $this->assertFalse($result); } public function testSyncAllAssignmentsRollsBackOnException(): void { $directoryGateway = $this->createMock(UserDirectoryGateway::class); $directoryGateway->method('listTenantIds') ->willThrowException(new \RuntimeException('db error')); $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); $dbSession = $this->createMock(DatabaseSessionRepository::class); $dbSession->expects($this->once())->method('beginTransaction'); $dbSession->expects($this->never())->method('commitTransaction'); $dbSession->expects($this->once())->method('rollbackTransaction'); $service = $this->newService( userTenantRepository: $userTenantRepo, directoryGateway: $directoryGateway, databaseSessionRepository: $dbSession ); $result = $service->syncAllAssignments(10, [1], [], []); $this->assertFalse($result); } public function testSyncAllAssignmentsBumpsAuthzOnlyOnce(): void { $directoryGateway = $this->createMock(UserDirectoryGateway::class); $directoryGateway->method('listTenantIds')->willReturn([1]); $directoryGateway->method('listActiveRoleIds')->willReturn([2]); $directoryGateway->method('listActiveDepartmentIdsByTenantIds')->willReturn([3]); $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); $userTenantRepo->method('replaceForUser')->willReturn(true); $userTenantRepo->method('listTenantIdsByUserId')->willReturn([1]); $userRoleRepo = $this->createMock(UserRoleRepositoryInterface::class); $userRoleRepo->method('replaceForUser')->willReturn(true); $userDeptRepo = $this->createMock(UserDepartmentRepositoryInterface::class); $userDeptRepo->method('replaceForUser')->willReturn(true); $permissionService = $this->createMock(PermissionService::class); $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); // bumpAuthzVersion should be called exactly once (not three times) $userWriteRepo->expects($this->once())->method('bumpAuthzVersion')->with(10); $dbSession = $this->createMock(DatabaseSessionRepository::class); $service = $this->newService( userWriteRepository: $userWriteRepo, userTenantRepository: $userTenantRepo, userRoleRepository: $userRoleRepo, userDepartmentRepository: $userDeptRepo, directoryGateway: $directoryGateway, permissionService: $permissionService, databaseSessionRepository: $dbSession ); $service->syncAllAssignments(10, [1], [2], [3]); } // ── bumpAuthzVersion ──────────────────────────────────────────────── public function testBumpAuthzVersionReturnsEarlyWhenUserIdIsZero(): void { $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); $userWriteRepo->expects($this->never())->method('bumpAuthzVersion'); $service = $this->newService(userWriteRepository: $userWriteRepo); $service->bumpAuthzVersion(0); } public function testBumpAuthzVersionReturnsEarlyWhenUserIdIsNegative(): void { $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); $userWriteRepo->expects($this->never())->method('bumpAuthzVersion'); $service = $this->newService(userWriteRepository: $userWriteRepo); $service->bumpAuthzVersion(-1); } public function testBumpAuthzVersionCallsRepository(): void { $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); $userWriteRepo->expects($this->once())->method('bumpAuthzVersion')->with(42); $service = $this->newService(userWriteRepository: $userWriteRepo); $service->bumpAuthzVersion(42); } // ── normalizeIdInput ──────────────────────────────────────────────── public function testNormalizeIdInputWithScalarInt(): void { $service = $this->newService(); $this->assertSame([5], $service->normalizeIdInput(5)); } public function testNormalizeIdInputWithArrayOfInts(): void { $service = $this->newService(); $this->assertSame([1, 2, 3], $service->normalizeIdInput([1, 2, 3])); } public function testNormalizeIdInputWithCommaSeparatedString(): void { $service = $this->newService(); $this->assertSame([1, 2, 3], $service->normalizeIdInput('1,2,3')); } public function testNormalizeIdInputWithNestedArrays(): void { $service = $this->newService(); $this->assertSame([1, 2, 3], $service->normalizeIdInput([[1, [2]], 3])); } public function testNormalizeIdInputFiltersNegativeValues(): void { $service = $this->newService(); $this->assertSame([1, 3], $service->normalizeIdInput([1, -2, 3, 0])); } public function testNormalizeIdInputRemovesDuplicates(): void { $service = $this->newService(); $this->assertSame([1, 2], $service->normalizeIdInput([1, 2, 1, 2])); } public function testNormalizeIdInputHandlesEmptyString(): void { $service = $this->newService(); $this->assertSame([], $service->normalizeIdInput('')); } public function testNormalizeIdInputHandlesEmptyArray(): void { $service = $this->newService(); $this->assertSame([], $service->normalizeIdInput([])); } // ── normalizeTenantIds ────────────────────────────────────────────── public function testNormalizeTenantIdsFiltersAgainstValidTenantIds(): void { $directoryGateway = $this->createMock(UserDirectoryGateway::class); $directoryGateway->expects($this->once())->method('listTenantIds')->willReturn([1, 2, 3]); $service = $this->newService(directoryGateway: $directoryGateway); $result = $service->normalizeTenantIds([1, 99, 2]); $this->assertSame([1, 2], $result); } public function testNormalizeTenantIdsFiltersNegativeAndZeroIds(): void { $directoryGateway = $this->createMock(UserDirectoryGateway::class); $directoryGateway->method('listTenantIds')->willReturn([1, 2]); $service = $this->newService(directoryGateway: $directoryGateway); $result = $service->normalizeTenantIds([0, -1, 1]); $this->assertSame([1], $result); } // ── Helper ────────────────────────────────────────────────────────── private function newService( ?UserWriteRepositoryInterface $userWriteRepository = null, ?UserTenantRepositoryInterface $userTenantRepository = null, ?UserRoleRepositoryInterface $userRoleRepository = null, ?UserDepartmentRepositoryInterface $userDepartmentRepository = null, ?UserDirectoryGateway $directoryGateway = null, ?PermissionService $permissionService = null, ?DatabaseSessionRepository $databaseSessionRepository = null, ?AssignableRoleService $assignableRoleService = null, ): UserAssignmentService { return new UserAssignmentService( $userWriteRepository ?? $this->createMock(UserWriteRepositoryInterface::class), $userTenantRepository ?? $this->createMock(UserTenantRepositoryInterface::class), $userRoleRepository ?? $this->createMock(UserRoleRepositoryInterface::class), $userDepartmentRepository ?? $this->createMock(UserDepartmentRepositoryInterface::class), $directoryGateway ?? $this->createMock(UserDirectoryGateway::class), $permissionService ?? $this->createMock(PermissionService::class), $databaseSessionRepository ?? $this->createMock(DatabaseSessionRepository::class), $assignableRoleService ?? $this->createMock(AssignableRoleService::class), ); } }