Repository: box/box-python-sdk
Pull request #354 (2018-10-25 18:17:51)

Metadata cascade policies

boxcla: Verified that @mattwiller has signed the CLA. Thanks for the pull request!

Patch:
diff --git a/boxsdk/client/client.py b/boxsdk/client/client.py
index d45e2c7..7b93bb2 100644
--- a/boxsdk/client/client.py
+++ b/boxsdk/client/client.py
@@ -1501,6 +1501,24 @@ def device_pinners(self, enterprise=None, direction=None, limit=None, marker=Non
             return_full_pages=False,
         )
 
+    def metadata_cascade_policy(self, policy_id):
+        """
+        Initializes a :class:`MetadataCascadePolicy` object with the given policy ID.
+
+        :param policy_id:
+            The ID of the cascade policy object
+        :type policy_id:
+            `unicode`
+        :returns:
+            The cascade policy object
+        :rtype:
+            :class:`MetadataCascadePolicy`
+        """
+        return self.translator.get('metadata_cascade_policy')(
+            session=self._session,
+            object_id=policy_id,
+        )
+
     def metadata_template(self, scope, template_key):
         """
         Initialize a :class:`MetadataTemplate` object with the given scope and template key.
diff --git a/boxsdk/object/__init__.py b/boxsdk/object/__init__.py
index c44e598..1baac62 100644
--- a/boxsdk/object/__init__.py
+++ b/boxsdk/object/__init__.py
@@ -25,6 +25,7 @@
     'legal_hold',
     'legal_hold_policy',
     'legal_hold_policy_assignment',
+    'metadata_cascade_policy',
     'metadata_template',
     'recent_item',
     'retention_policy',
diff --git a/boxsdk/object/base_object.py b/boxsdk/object/base_object.py
index a6f201a..75877ae 100644
--- a/boxsdk/object/base_object.py
+++ b/boxsdk/object/base_object.py
@@ -83,7 +83,10 @@ def get(self, fields=None, headers=None):
         url = self.get_url()
         params = {'fields': ','.join(fields)} if fields else None
         box_response = self._session.get(url, params=params, headers=headers)
-        return self.__class__(self._session, self._object_id, box_response.json())
+        return self.translator.translate(
+            session=self._session,
+            response_object=box_response.json(),
+        )
 
     @api_call
     def update_info(self, data, params=None, headers=None, **kwargs):
@@ -122,11 +125,9 @@ def update_info(self, data, params=None, headers=None, **kwargs):
         """
         url = self.get_url()
         box_response = self._session.put(url, data=json.dumps(data), params=params, headers=headers, **kwargs)
-        response = box_response.json()
-        return self.__class__(
+        return self.translator.translate(
             session=self._session,
-            object_id=self._object_id,
-            response_object=response,
+            response_object=box_response.json(),
         )
 
     @api_call
diff --git a/boxsdk/object/file.py b/boxsdk/object/file.py
index 2028015..89eb69b 100644
--- a/boxsdk/object/file.py
+++ b/boxsdk/object/file.py
@@ -224,9 +224,8 @@ def update_contents_with_stream(
         file_response = self._session.post(url, expect_json_response=False, files=files, headers=headers).json()
         if 'entries' in file_response:
             file_response = file_response['entries'][0]
-        return self.__class__(
+        return self.translator.translate(
             session=self._session,
-            object_id=self._object_id,
             response_object=file_response,
         )
 
@@ -684,8 +683,7 @@ def copy(self, parent_folder, name=None, file_version=None):
             data['version'] = file_version.object_id
         box_response = self._session.post(url, data=json.dumps(data))
         response = box_response.json()
-        return self.__class__(
+        return self.translator.translate(
             session=self._session,
-            object_id=response['id'],
             response_object=response,
         )
diff --git a/boxsdk/object/folder.py b/boxsdk/object/folder.py
index 3a4b687..a61db9a 100644
--- a/boxsdk/object/folder.py
+++ b/boxsdk/object/folder.py
@@ -500,3 +500,72 @@ def delete(self, recursive=True, etag=None):
         """
         # pylint:disable=arguments-differ
         return super(Folder, self).delete({'recursive': recursive}, etag)
+
+    @api_call
+    def get_metadata_cascade_policies(self, owner_enterprise=None, limit=None, marker=None, fields=None):
+        """
+        Get the metadata cascade policies current applied to the folder.
+
+        :param owner_enterprise:
+            Which enterprise's metadata templates to get cascade policies for.  This defauls to the current
+            enterprise.
+        :type owner_enterprise:
+            :class:`Enterprise`
+        :param limit:
+            The maximum number of entries to return per page. If not specified, then will use the server-side default.
+        :type limit:
+            `int` or None
+        :param marker:
+            The paging marker to start paging from.
+        :type marker:
+            `unicode` or None
+        :param fields:
+            List of fields to request.
+        :type fields:
+            `Iterable` of `unicode`
+        :returns:
+            An iterator of the cascade policies attached on the folder.
+        :rtype:
+            :class:`BoxObjectCollection`
+        """
+        additional_params = {
+            'folder_id': self.object_id,
+        }
+        if owner_enterprise is not None:
+            additional_params['owner_enterprise_id'] = owner_enterprise.object_id
+
+        return MarkerBasedObjectCollection(
+            url=self._session.get_url('metadata_cascade_policies'),
+            session=self._session,
+            additional_params=additional_params,
+            limit=limit,
+            marker=marker,
+            fields=fields,
+            return_full_pages=False,
+        )
+
+    @api_call
+    def cascade_metadata(self, metadata_template):
+        """
+        Create a metadata cascade policy to apply the metadata instance values on the folder for the given metadata
+        template to all files within the folder.
+
+        :param metadata_template:
+            The metadata template to cascade values for
+        :type metadata_template:
+            :class:`MetadataTemplate`
+        :returns:
+            The created metadata cascade policy
+        :rtype:
+            :class:`MetadataCascadePolicy`
+        """
+        url = self._session.get_url('metadata_cascade_policies')
+
+        body = {
+            'folder_id': self.object_id,
+            'scope': metadata_template.scope,
+            'templateKey': metadata_template.template_key,
+        }
+
+        response = self._session.post(url, data=json.dumps(body)).json()
+        return self.translator.translate(self._session, response)
diff --git a/boxsdk/object/item.py b/boxsdk/object/item.py
index deb00a1..f47a6b3 100644
--- a/boxsdk/object/item.py
+++ b/boxsdk/object/item.py
@@ -155,9 +155,8 @@ def copy(self, parent_folder, name=None):
             data['name'] = name
         box_response = self._session.post(url, data=json.dumps(data))
         response = box_response.json()
-        return self.__class__(
+        return self.translator.translate(
             session=self._session,
-            object_id=response['id'],
             response_object=response,
         )
 
diff --git a/boxsdk/object/metadata_cascade_policy.py b/boxsdk/object/metadata_cascade_policy.py
new file mode 100644
index 0000000..68561fd
--- /dev/null
+++ b/boxsdk/object/metadata_cascade_policy.py
@@ -0,0 +1,51 @@
+# coding: utf-8
+from __future__ import unicode_literals
+
+import json
+
+from .base_object import BaseObject
+from ..util.text_enum import TextEnum
+from ..util.api_call_decorator import api_call
+
+
+class CascadePolicyConflictResolution(TextEnum):
+    PRESERVE_EXISTING = 'none'
+    OVERWRITE = 'overwrite'
+
+
+class MetadataCascadePolicy(BaseObject):
+    """Represents a metadata cascade policy, which applies folder metadata to files in that folder."""
+
+    _item_type = 'metadata_cascade_policy'
+
+    def get_url(self, *args):
+        """
+        Base class override to account for the correct pluralization.
+
+        :rtype:
+            `unicode`
+        """
+        return self._session.get_url('metadata_cascade_policies', self.object_id, *args)
+
+    @api_call
+    def force_apply(self, conflict_resolution):
+        """
+        Applies the metadata values on the folder to all files within the folder.  The conflict resolution
+        parameter determines how conflicts when the same metadata template is already applied to a file will be
+        handled; either the file's existing values or the folder values can be given precendence.
+
+        :param conflict_resolution:
+            How conflicting metadata values should be reolved
+        :type conflict_resolution:
+            :class:`CascadePolicyConflictResolution`
+        :returns:
+            Whether the force application succeeded.
+        :rtype:
+            `bool`
+        """
+        url = self.get_url('apply')
+        body = {
+            'conflict_resolution': conflict_resolution,
+        }
+        response = self._session.post(url, data=json.dumps(body), expect_json_response=False)
+        return response.ok
diff --git a/boxsdk/object/metadata_template.py b/boxsdk/object/metadata_template.py
index c1b8200..7440ed1 100644
--- a/boxsdk/object/metadata_template.py
+++ b/boxsdk/object/metadata_template.py
@@ -332,8 +332,7 @@ def update_info(self, updates):
         """
         url = self.get_url()
         response = self._session.put(url, data=json.dumps(updates.json())).json()
-        return self.__class__(
+        return self.translator.translate(
             session=self._session,
-            object_id=self.object_id,
             response_object=response,
         )
diff --git a/docs/metadata_cascade_policies.md b/docs/metadata_cascade_policies.md
new file mode 100644
index 0000000..af434d4
--- /dev/null
+++ b/docs/metadata_cascade_policies.md
@@ -0,0 +1,114 @@
+Metadata Cascade Policies
+=========================
+
+Metadata Cascade Policies allow the metadata values on a folder to be applied to the files within that folder.  A
+cascade policy associates the folder with a specific metadata template whose instance values on the folder should be
+cascaded to files within the folder.
+
+> __Note:__ The Metadata Cascade Policy endpoints are currently in beta. Please email
+> [betas+metadata@box.com](mailto:betas+metadata@box.com) if you would like to enable this beta feature for your
+> enterprise.  If you do not have this enabled for your enterprise, you will get a 403 error.
+
+<!-- START doctoc generated TOC please keep comment here to allow auto update -->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+
+
+- [Create a Metadata Cascade Policy](#create-a-metadata-cascade-policy)
+- [Get Information About a Metadata Cascade Policy](#get-information-about-a-metadata-cascade-policy)
+- [Get Cascade Policies on a Folder](#get-cascade-policies-on-a-folder)
+- [Force Apply Cascade Policy](#force-apply-cascade-policy)
+- [Remove Cascade Policy](#remove-cascade-policy)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+Create a Metadata Cascade Policy
+--------------------------------
+
+To create a metadata cascade policy on a folder, call [`folder.cascade_metadata(metadata_template)`][cascade_metadata]
+with the [`MetadataTemplate`][metadata_template_class] whose values should be cascaded within the folder.  This
+method returns a [`MetadataCascadePolicy`][cascade_policy_class] object representing the newly-created policy.
+
+```python
+folder = client.folder(folder_id='22222')
+metadata_template = client.metadata_template('enterprise', 'securityClassiciation')
+
+cascade_policy = folder.cascade_metadata(metadata_template)
+print('Folder {0} has a metadata cascade policy for {1} template "{2}"'.format(
+    cascade_policy.parent.id,
+    cascade_policy.scope,
+    cascade_policy.templateKey,
+))
+```
+
+[cascade_metadata]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.object.html#boxsdk.object.folder.Folder.cascade_metadata
+[metadata_template_class]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.object.html#boxsdk.object.metadata_template.MetadataTemplate
+[cascade_policy_class]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.object.html#boxsdk.object.metadata_cascade_policy.MetadataCascadePolicy
+
+Get Information About a Metadata Cascade Policy
+-----------------------------------------------
+
+To retrieve information about a metadata cascade policy, first call
+[`client.metadata_cascade_policy(policy_id)`][initializer] with the ID of the cascade policy to initialize the
+[`MetadataCascadePolicy`][cascade_policy_class] object, then call [`cascade_policy.get()`][get] to retrieve data about
+the policy.  This method returns a new [`MetadataCascadePolicy`][cascade_policy_class] object with fields populated by
+data from the API, leaving the original object unmodified.
+
+```python
+cascade_policy = client.metadata_cascade_policy('84113349-794d-445c-b93c-d8481b223434').get()
+print('Cascade policy applies to a template owned by enterprise {0}'.format(cascade_policy.owner_enterprise.id))
+```
+
+[initializer]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.client.html#boxsdk.client.client.Client.metadata_cascade_policy
+[get]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.object.html#boxsdk.object.base_object.BaseObject.get
+
+Get Cascade Policies on a Folder
+--------------------------------
+
+To get a list of the cascade policies applied to a folder, call
+[`folder.get_metadata_cascade_policies(owner_enterprise=None, limit=None, marker=None, fields=None)`][get_metadata_cascade_policies].
+You can optionally pass an [`Enterprise`][enterprise_class] object via the `owner_enterprise` parameter to retrieve
+cascade policies related to templates for a specific enterprise; if not specified, this defaults to the current
+enterprise.  This method returns a [`BoxObjectCollection`][box_object_collection] that allows you to iterate over the
+[`MetadataCascadePolicy`][cascade_policy_class] objects in the collection.
+
+```python
+cascade_policies = client.folder(folder_id='22222').get_metadata_cascade_policies()
+for policy in cascade_policies:
+    print('Metadata template {1} is cascaded'.format(policy.templateKey))
+```
+
+[get_metadata_cascade_policies]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.object.html#boxsdk.object.folder.Folder.get_metadata_cascade_policies
+[enterprise_class]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.object.html#boxsdk.object.enterprise.Enterprise
+[box_object_collection]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.pagination.html#boxsdk.pagination.box_object_collection.BoxObjectCollection
+
+Force Apply Cascade Policy
+--------------------------
+
+A Cascade Policy can be forced to re-apply to all files in the associated folder by calling
+[`cascade_policy.force_apply(conflict_resolution)`][force_apply] with the conflict resolution strategy to use.  If
+files in the folder already have metadata values that conflict with the ones being force applied from the folder, you
+can choose to either preserve the existing values or overwrite them with the folder's values.  This method returns
+`True` to indicate that the force application was successful.
+
+```python
+from boxsdk.object.metadata_cascade_policy import CascadePolicyConflictResolution
+
+cascade_policy = client.metadata_cascade_policy(policy_id='84113349-794d-445c-b93c-d8481b223434')
+cascade_policy.force_apply(CascadePolicyConflictResolution.PRESERVE_EXISTING)
+print('Cascade policy was force applied!')
+```
+
+[force_apply]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.object.html#boxsdk.object.metadata_cascade_policy.MetadataCascadePolicy.force_apply
+
+Remove Cascade Policy
+---------------------
+
+A metadata cascade policy can be removed from a folder by calling [`cascade_policy.delete()`][delete].  This method
+returns `True` to indicate that the deletion was successful.
+
+```python
+client.metadata_cascade_policy(policy_id='84113349-794d-445c-b93c-d8481b223434').delete()
+print('Cascade policy successfully removed')
+```
+
+[delete]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.object.html#boxsdk.object.base_object.BaseObject.delete


Test patch:
diff --git a/test/unit/client/test_client.py b/test/unit/client/test_client.py
index b4b9fea..72fe196 100644
--- a/test/unit/client/test_client.py
+++ b/test/unit/client/test_client.py
@@ -48,6 +48,7 @@
 from boxsdk.object.legal_hold import LegalHold
 from boxsdk.object.legal_hold_policy import LegalHoldPolicy
 from boxsdk.object.legal_hold_policy_assignment import LegalHoldPolicyAssignment
+from boxsdk.object.metadata_cascade_policy import MetadataCascadePolicy
 from boxsdk.object.task import Task
 from boxsdk.object.task_assignment import TaskAssignment
 from boxsdk.object.webhook import Webhook
@@ -401,6 +402,7 @@ def device_pins_response(device_pin_id_1, device_pin_id_2):
     (GroupMembership, 'group_membership'),
     (Enterprise, 'enterprise'),
     (Webhook, 'webhook'),
+    (MetadataCascadePolicy, 'metadata_cascade_policy'),
     (UploadSession, 'upload_session'),
     (StoragePolicy, 'storage_policy'),
     (StoragePolicyAssignment, 'storage_policy_assignment'),
diff --git a/test/unit/conftest.py b/test/unit/conftest.py
index b2402e8..752cabd 100644
--- a/test/unit/conftest.py
+++ b/test/unit/conftest.py
@@ -57,13 +57,14 @@ def mock_box_session(translator):
 
 
 @pytest.fixture()
-def mock_box_session_2():
+def mock_box_session_2(translator):
     mock_session = MagicMock(Session)
     # pylint:disable=protected-access
     mock_session._api_config = API()
     mock_session._client_config = Client()
     # pylint:enable=protected-access
     mock_session.get_url.side_effect = lambda *args, **kwargs: Session.get_url(mock_session, *args, **kwargs)
+    mock_session.translator = translator
     return mock_session
 
 
diff --git a/test/unit/object/conftest.py b/test/unit/object/conftest.py
index 9d79594..936a11b 100644
--- a/test/unit/object/conftest.py
+++ b/test/unit/object/conftest.py
@@ -17,6 +17,7 @@
 from boxsdk.object.group_membership import GroupMembership
 from boxsdk.object.legal_hold_policy import LegalHoldPolicy
 from boxsdk.object.legal_hold_policy_assignment import LegalHoldPolicyAssignment
+from boxsdk.object.metadata_template import MetadataTemplate
 from boxsdk.object.user import User
 from boxsdk.object.retention_policy import RetentionPolicy
 from boxsdk.object.retention_policy_assignment import RetentionPolicyAssignment
@@ -344,3 +345,13 @@ def shared_link_unshared_at(request):
 ])
 def comment_params(request):
     return request.param
+
+
+@pytest.fixture()
+def test_metadata_template(mock_box_session):
+    fake_response = {
+        'type': 'metadata_template',
+        'scope': 'enterprise',
+        'templateKey': 'vContract',
+    }
+    return MetadataTemplate(mock_box_session, None, fake_response)
diff --git a/test/unit/object/test_file.py b/test/unit/object/test_file.py
index bdb30ca..188e140 100644
--- a/test/unit/object/test_file.py
+++ b/test/unit/object/test_file.py
@@ -445,8 +445,17 @@ def test_get_shared_link_download_url(
 ):
     # pylint:disable=redefined-outer-name, protected-access
     expected_url = test_file.get_url()
-    mock_box_session.put.return_value.json.return_value = {'shared_link': {'url': None, 'download_url': test_url}}
-    expected_data = {'shared_link': {}}
+    mock_box_session.put.return_value.json.return_value = {
+        'type': test_file.object_type,
+        'id': test_file.object_id,
+        'shared_link': {
+            'url': None,
+            'download_url': test_url,
+        },
+    }
+    expected_data = {
+        'shared_link': {},
+    }
     if shared_link_access is not None:
         expected_data['shared_link']['access'] = shared_link_access
     if shared_link_unshared_at is not None:
diff --git a/test/unit/object/test_folder.py b/test/unit/object/test_folder.py
index a2e208a..94b13d1 100644
--- a/test/unit/object/test_folder.py
+++ b/test/unit/object/test_folder.py
@@ -10,7 +10,9 @@
 from boxsdk.config import API
 from boxsdk.exception import BoxAPIException
 from boxsdk.network.default_network import DefaultNetworkResponse
+from boxsdk.object.enterprise import Enterprise
 from boxsdk.object.file import File
+from boxsdk.object.metadata_cascade_policy import MetadataCascadePolicy
 from boxsdk.object.web_link import WebLink
 from boxsdk.object.collaboration import Collaboration, CollaborationRole
 from boxsdk.object.folder import Folder, FolderSyncState
@@ -382,3 +384,72 @@ def test_create_web_link_returns_the_correct_web_link_object(test_folder, mock_b
     assert new_web_link.url == test_web_link_url
     assert new_web_link.name == expected_name
     assert new_web_link.description == description
+
+
+def test_get_metadata_cascade_policies(test_folder, mock_box_session):
+    expected_url = '{0}/metadata_cascade_policies'.format(API.BASE_API_URL)
+    params = {'folder_id': test_folder.object_id}
+    mock_box_session.get.return_value.json.return_value = {
+        'entries': [
+            {
+                'id': '84113349-794d-445c-b93c-d8481b223434',
+                'type': 'metadata_cascade_policy',
+                'parent': {
+                    'type': 'folder',
+                    'id': test_folder.object_id,
+                },
+                'scope': 'enterprise_11111',
+                'templateKey': 'testTemplate',
+            }
+        ],
+        'next_marker': None,
+        'prev_marker': None,
+    }
+
+    cascade_policies = test_folder.get_metadata_cascade_policies()
+    policy = cascade_policies.next()
+
+    mock_box_session.get.assert_called_once_with(expected_url, params=params)
+    assert isinstance(policy, MetadataCascadePolicy)
+    assert policy.object_id == '84113349-794d-445c-b93c-d8481b223434'
+    assert policy.scope == 'enterprise_11111'
+    assert policy.templateKey == 'testTemplate'
+    # pylint: disable=protected-access
+    assert policy._session == mock_box_session
+
+
+def test_cascade_metadata(test_folder, mock_box_session, test_metadata_template):
+    expected_url = '{0}/metadata_cascade_policies'.format(API.BASE_API_URL)
+    expected_body = {
+        'folder_id': test_folder.object_id,
+        'scope': test_metadata_template.scope,
+        'templateKey': test_metadata_template.template_key,
+    }
+    mock_box_session.post.return_value.json.return_value = {
+        'id': '84113349-794d-445c-b93c-d8481b223434',
+        'type': 'metadata_cascade_policy',
+        'owner_enterprise': {
+            'type': 'enterprise',
+            'id': '11111',
+        },
+        'parent': {
+            'type': 'folder',
+            'id': test_folder.object_id,
+        },
+        'scope': test_metadata_template.scope,
+        'templateKey': test_metadata_template.template_key,
+    }
+
+    cascade_policy = test_folder.cascade_metadata(test_metadata_template)
+
+    mock_box_session.post.assert_called_once_with(expected_url, data=json.dumps(expected_body))
+    assert isinstance(cascade_policy, MetadataCascadePolicy)
+    assert cascade_policy.object_id == '84113349-794d-445c-b93c-d8481b223434'
+    enterprise = cascade_policy.owner_enterprise
+    assert isinstance(enterprise, Enterprise)
+    assert enterprise.object_id == '11111'
+    folder = cascade_policy.parent
+    assert isinstance(folder, Folder)
+    assert folder.object_id == test_folder.object_id
+    assert cascade_policy.scope == test_metadata_template.scope
+    assert cascade_policy.templateKey == test_metadata_template.template_key
diff --git a/test/unit/object/test_item.py b/test/unit/object/test_item.py
index 2eb1dfc..2a56d03 100644
--- a/test/unit/object/test_item.py
+++ b/test/unit/object/test_item.py
@@ -136,7 +136,13 @@ def test_get_shared_link(
     # pylint:disable=redefined-outer-name, protected-access
     test_item, _ = test_item_and_response
     expected_url = test_item.get_url()
-    mock_box_session.put.return_value.json.return_value = {'shared_link': {'url': test_url}}
+    mock_box_session.put.return_value.json.return_value = {
+        'type': test_item.object_type,
+        'id': test_item.object_id,
+        'shared_link': {
+            'url': test_url,
+        },
+    }
     expected_data = {'shared_link': {}}
     if shared_link_access is not None:
         expected_data['shared_link']['access'] = shared_link_access
@@ -171,7 +177,11 @@ def test_remove_shared_link(test_item_and_response, mock_box_session, etag, if_m
     # pylint:disable=redefined-outer-name, protected-access
     test_item, _ = test_item_and_response
     expected_url = test_item.get_url()
-    mock_box_session.put.return_value.json.return_value = {'shared_link': None}
+    mock_box_session.put.return_value.json.return_value = {
+        'type': test_item.object_type,
+        'id': test_item.object_id,
+        'shared_link': None,
+    }
     removed = test_item.remove_shared_link(etag=etag)
     mock_box_session.put.assert_called_once_with(
         expected_url,
@@ -205,7 +215,9 @@ def test_add_to_collection(test_item_and_response, mock_box_session, mock_collec
         'collections': expected_collections
     }
     mock_response = {
-        'collections': current_collections
+        'type': test_item.object_type,
+        'id': test_item.object_id,
+        'collections': current_collections,
     }
     mock_box_session.get.return_value.json.return_value = mock_response
     mock_box_session.put.return_value = mock_item_response
@@ -226,7 +238,9 @@ def test_remove_from_collection(test_item_and_response, mock_box_session, mock_c
         'collections': expected_collections
     }
     mock_response = {
-        'collections': current_collections
+        'type': test_item.object_type,
+        'id': test_item.object_id,
+        'collections': current_collections,
     }
     mock_box_session.get.return_value.json.return_value = mock_response
     mock_box_session.put.return_value = mock_item_response
diff --git a/test/unit/object/test_metadata_cascade_policy.py b/test/unit/object/test_metadata_cascade_policy.py
new file mode 100644
index 0000000..eedbaed
--- /dev/null
+++ b/test/unit/object/test_metadata_cascade_policy.py
@@ -0,0 +1,74 @@
+# coding: utf-8
+
+from __future__ import unicode_literals, absolute_import
+
+import json
+import pytest
+
+from boxsdk.config import API
+from boxsdk.object.enterprise import Enterprise
+from boxsdk.object.folder import Folder
+from boxsdk.object.metadata_cascade_policy import MetadataCascadePolicy, CascadePolicyConflictResolution
+
+
+@pytest.fixture()
+def test_cascade_policy(mock_box_session):
+    return MetadataCascadePolicy(mock_box_session, 'test_cascade_policy_id')
+
+
+def test_get(test_cascade_policy, mock_box_session):
+    expected_url = '{0}/metadata_cascade_policies/{1}'.format(API.BASE_API_URL, test_cascade_policy.object_id)
+    mock_box_session.get.return_value.json.return_value = {
+        'id': '84113349-794d-445c-b93c-d8481b223434',
+        'type': 'metadata_cascade_policy',
+        'owner_enterprise': {
+            'type': 'enterprise',
+            'id': '11111',
+        },
+        'parent': {
+            'type': 'folder',
+            'id': '22222',
+        },
+        'scope': 'enterprise_11111',
+        'templateKey': 'testTemplate',
+    }
+
+    cascade_policy = test_cascade_policy.get()
+
+    mock_box_session.get.assert_called_once_with(expected_url, params=None, headers=None)
+    assert isinstance(cascade_policy, MetadataCascadePolicy)
+    enterprise = cascade_policy.owner_enterprise
+    assert isinstance(enterprise, Enterprise)
+    assert enterprise.object_id == '11111'
+    folder = cascade_policy.parent
+    assert isinstance(folder, Folder)
+    assert folder.object_id == '22222'
+    assert cascade_policy.scope == 'enterprise_11111'
+    assert cascade_policy.templateKey == 'testTemplate'
+
+
+def test_delete(test_cascade_policy, mock_box_session):
+    expected_url = '{0}/metadata_cascade_policies/{1}'.format(API.BASE_API_URL, test_cascade_policy.object_id)
+    mock_box_session.delete.return_value.ok = True
+
+    result = test_cascade_policy.delete()
+
+    mock_box_session.delete.assert_called_once_with(expected_url, expect_json_response=False, headers=None, params={})
+    assert result is True
+
+
+def test_force_apply(test_cascade_policy, mock_box_session):
+    expected_url = '{0}/metadata_cascade_policies/{1}/apply'.format(API.BASE_API_URL, test_cascade_policy.object_id)
+    expected_body = {
+        'conflict_resolution': 'overwrite',
+    }
+    mock_box_session.post.return_value.ok = True
+
+    result = test_cascade_policy.force_apply(CascadePolicyConflictResolution.OVERWRITE)
+
+    mock_box_session.post.assert_called_once_with(
+        expected_url,
+        data=json.dumps(expected_body),
+        expect_json_response=False
+    )
+    assert result is True
diff --git a/test/unit/object/test_metadata_template.py b/test/unit/object/test_metadata_template.py
index a2d18ea..1fc4a16 100644
--- a/test/unit/object/test_metadata_template.py
+++ b/test/unit/object/test_metadata_template.py
@@ -1,22 +1,11 @@
 from __future__ import unicode_literals, absolute_import
 
 import json
-import pytest
 
 from boxsdk.config import API
 from boxsdk.object.metadata_template import MetadataTemplate, MetadataField, MetadataFieldType
 
 
-@pytest.fixture()
-def test_metadata_template(mock_box_session):
-    fake_response = {
-        'type': 'metadata_template',
-        'scope': 'enterprise',
-        'templateKey': 'vContract',
-    }
-    return MetadataTemplate(mock_box_session, None, fake_response)
-
-
 def test_get(test_metadata_template, mock_box_session):
     expected_url = '{0}/metadata_templates/{1}/{2}/schema'.format(
         API.BASE_API_URL,
@@ -24,6 +13,7 @@ def test_get(test_metadata_template, mock_box_session):
         test_metadata_template.template_key,
     )
     mock_box_session.get.return_value.json.return_value = {
+        'type': 'metadata_template',
         'scope': 'enterprise',
         'displayName': 'Vendor Contract',
         'hidden': True,
@@ -145,6 +135,7 @@ def test_update_info(test_metadata_template, mock_box_session):
     ]
 
     mock_box_session.put.return_value.json.return_value = {
+        'type': 'metadata_template',
         'scope': 'enterprise',
         'displayName': 'Vendor Contract',
         'hidden': False,
diff --git a/test/unit/object/test_storage_policy_assignment.py b/test/unit/object/test_storage_policy_assignment.py
index decca98..ef15547 100644
--- a/test/unit/object/test_storage_policy_assignment.py
+++ b/test/unit/object/test_storage_policy_assignment.py
@@ -32,7 +32,7 @@ def test_get(test_storage_policy_assignment, mock_box_session):
 def test_update(test_storage_policy_assignment, mock_box_session):
     new_policy_id = '1234'
     expected_url = '{0}/storage_policy_assignments/{1}'.format(API.BASE_API_URL, test_storage_policy_assignment.object_id)
-    mock_box_session.get.return_value.json.return_value = {
+    mock_box_session.put.return_value.json.return_value = {
         'type': 'storage_policy_assignment',
         'id': new_policy_id,
     }