Skip to content

Commit 7650e21

Browse files
Test limit/offset and marker pagination
1 parent f679dce commit 7650e21

File tree

6 files changed

+446
-0
lines changed

6 files changed

+446
-0
lines changed

test/unit/object/test_folder.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from boxsdk.object.file import File
1414
from boxsdk.object.collaboration import Collaboration, CollaborationRole
1515
from boxsdk.object.folder import Folder, FolderSyncState
16+
from boxsdk.pagination.limit_offset_based_object_collection import LimitOffsetBasedObjectCollection
17+
from boxsdk.pagination.marker_based_object_collection import MarkerBasedObjectCollection
1618
from boxsdk.session.box_session import BoxResponse
1719

1820

@@ -139,6 +141,16 @@ def test_get_items(test_folder, mock_box_session, mock_items_response, limit, of
139141
assert all([i.id == e.object_id for i, e in zip(items, expected_items)])
140142

141143

144+
def test_get_items_marker_returns_marker_instance(test_folder):
145+
limit_offset_object_collection = test_folder.get_items_limit_offset()
146+
assert isinstance(limit_offset_object_collection, LimitOffsetBasedObjectCollection)
147+
148+
149+
def test_get_items_limit_offset_returns_limit_offset_instance(test_folder):
150+
marker_object_collection = test_folder.get_items_marker()
151+
assert isinstance(marker_object_collection, MarkerBasedObjectCollection)
152+
153+
142154
@pytest.mark.parametrize('is_stream', (True, False))
143155
def test_upload(
144156
test_folder,

test/unit/pagination/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# coding: utf-8
2+
3+
from __future__ import unicode_literals
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# coding: utf-8
2+
3+
from __future__ import unicode_literals, absolute_import
4+
from abc import ABCMeta, abstractmethod
5+
from six import add_metaclass
6+
from six.moves import range # pylint:disable=redefined-builtin
7+
import pytest
8+
9+
from boxsdk.util.translator import Translator
10+
11+
12+
@add_metaclass(ABCMeta)
13+
class BoxObjectCollectionTestBase(object):
14+
NUM_ENTRIES = 25
15+
16+
@pytest.fixture()
17+
def translator(self):
18+
return Translator()
19+
20+
@pytest.fixture()
21+
def entries(self):
22+
all_entries = []
23+
for i in range(self.NUM_ENTRIES):
24+
all_entries.append({
25+
"type": "file",
26+
"id": str(1000 + i),
27+
"sequence_id": str(i),
28+
"etag": str(10 + i),
29+
"name": "file_{0}.txt".format(i),
30+
})
31+
return all_entries
32+
33+
@abstractmethod
34+
@pytest.fixture()
35+
def mock_items_response(self, entries):
36+
raise NotImplementedError
37+
38+
@abstractmethod
39+
@pytest.fixture()
40+
def mock_session(self, translator, mock_items_response):
41+
raise NotImplementedError
42+
43+
@abstractmethod
44+
def _object_collection_instance(self, session, limit, return_full_pages=False, starting_pointer=None):
45+
"""
46+
:type session: :class:`BoxSession`
47+
:type limit: `int`
48+
:type return_full_pages: `bool`
49+
:type starting_pointer: varies
50+
:rtype: :class:`BoxObjectCollection`
51+
"""
52+
raise NotImplementedError
53+
54+
def _assert_items_dict_and_objects_same(self, expected_items_dict, returned_item_objects):
55+
"""
56+
A fixture very specific to this test class. Asserts that the list of items in dictionary form are the
57+
same (at least in name, and in quantity) as a list of BaseObjects.
58+
59+
:param expected_items_dict: List of expected items, represented as a dictionary.
60+
:type expected_items_dict: `list` of `dict`
61+
:param returned_item_objects: List of item instances (BaseObject) returned by SUT.
62+
:type returned_item_objects: `list` of class:`BaseObject`
63+
"""
64+
expected_num = len(expected_items_dict)
65+
actual_num = len(returned_item_objects)
66+
assert actual_num == expected_num, 'Expected {0} items, got {1}'.format(expected_num, actual_num)
67+
returned_item_names = [item.name for item in returned_item_objects]
68+
for expected_item_dict in expected_items_dict:
69+
assert expected_item_dict['name'] in returned_item_names, 'Missing item: {0}'.format(expected_item_dict['name'])
70+
71+
@pytest.mark.parametrize('return_full_pages', (True, False))
72+
@pytest.mark.parametrize('limit', (1, 3, 5, NUM_ENTRIES, 1000))
73+
def test_object_collection_pages_through_all_entries(self, mock_session, entries, limit, return_full_pages):
74+
"""
75+
Tests the basic iteration functionality of the box object collection.
76+
"""
77+
object_collection = self._object_collection_instance(mock_session, limit, return_full_pages)
78+
iterated_items = []
79+
for item_or_page in object_collection:
80+
if return_full_pages:
81+
iterated_items.extend(item_or_page)
82+
else:
83+
iterated_items.append(item_or_page)
84+
self._assert_items_dict_and_objects_same(entries, iterated_items)
85+
86+
def test_new_object_collection_starts_off_from_last_pointer(self, mock_session, entries):
87+
"""
88+
Start paging with one object collection instance, and then finish paging with a new object collection
89+
instance, starting off from the next_pointer() of the previous instance.
90+
91+
The iterated items should be identical as if it were iterated through with just one object collection
92+
instance.
93+
"""
94+
iterated_items = []
95+
object_collection = self._object_collection_instance(mock_session, limit=5, return_full_pages=True)
96+
iterated_items.extend(object_collection.next())
97+
next_pointer = object_collection.next_pointer()
98+
99+
# Create a new object collection, starting off where the previous collection left off.
100+
new_object_collection = self._object_collection_instance(
101+
mock_session,
102+
limit=5,
103+
return_full_pages=True,
104+
starting_pointer=next_pointer
105+
)
106+
for page in new_object_collection:
107+
iterated_items.extend(page)
108+
109+
self._assert_items_dict_and_objects_same(entries, iterated_items)
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# coding: utf-8
2+
3+
from __future__ import unicode_literals, absolute_import
4+
import json
5+
from mock import Mock, PropertyMock
6+
import pytest
7+
8+
from boxsdk.network.default_network import DefaultNetworkResponse
9+
from boxsdk.pagination.limit_offset_based_object_collection import LimitOffsetBasedObjectCollection
10+
from boxsdk.session.box_session import BoxResponse, BoxSession
11+
from .box_object_collection_test_base import BoxObjectCollectionTestBase
12+
13+
14+
class TestLimitOffsetBasedObjectCollection(BoxObjectCollectionTestBase):
15+
DEFAULT_LIMIT = 100
16+
17+
@pytest.fixture()
18+
def mock_items_response(self, entries):
19+
"""Baseclass override."""
20+
# pylint:disable=redefined-outer-name
21+
def get_response(limit, offset):
22+
mock_box_response = Mock(BoxResponse)
23+
mock_network_response = Mock(DefaultNetworkResponse)
24+
mock_box_response.network_response = mock_network_response
25+
mock_box_response.json.return_value = mock_json = {
26+
'entries': entries[offset:limit + offset],
27+
'total_count': len(entries),
28+
'limit': limit,
29+
}
30+
mock_box_response.content = json.dumps(mock_json).encode()
31+
mock_box_response.status_code = 200
32+
mock_box_response.ok = True
33+
return mock_box_response
34+
return get_response
35+
36+
@pytest.fixture()
37+
def mock_session(self, translator, mock_items_response):
38+
"""Baseclass override."""
39+
mock_box_session = Mock(BoxSession)
40+
type(mock_box_session).translator = PropertyMock(
41+
return_value=translator
42+
)
43+
44+
def mock_items_side_effect(_, params):
45+
limit = min(params.get('limit', self.DEFAULT_LIMIT), self.DEFAULT_LIMIT)
46+
offset = params.get('offset', 0)
47+
return mock_items_response(limit, offset)
48+
49+
mock_box_session.get.side_effect = mock_items_side_effect
50+
return mock_box_session
51+
52+
def _object_collection_instance(self, session, limit=None, return_full_pages=False, starting_pointer=None):
53+
"""Baseclass override."""
54+
if starting_pointer is None:
55+
starting_pointer = 0
56+
return LimitOffsetBasedObjectCollection(
57+
session,
58+
'/some/endpoint',
59+
limit=limit,
60+
return_full_pages=return_full_pages,
61+
offset=starting_pointer,
62+
)
63+
64+
@pytest.mark.parametrize('return_full_pages', (True, False))
65+
def test_object_collection_sets_next_pointer_correctly(self, mock_session, return_full_pages):
66+
page_size = 10
67+
object_collection = LimitOffsetBasedObjectCollection(
68+
mock_session,
69+
'/some/endpoint',
70+
limit=page_size,
71+
return_full_pages=return_full_pages,
72+
)
73+
74+
assert object_collection.next_pointer() == 0
75+
object_collection.next()
76+
assert object_collection.next_pointer() == page_size
77+
78+
# Iterate to the last page, which doesn't return a full page.
79+
[_ for _ in object_collection] # pylint:disable=pointless-statement
80+
assert object_collection.next_pointer() == self.NUM_ENTRIES
81+
82+
def test_object_collection_raises_stop_iteration_when_starting_offset_is_too_far(self, mock_session, entries):
83+
"""
84+
If the specified initial offset for the object collection is higher than the total number of items,
85+
then the first call to next() on the object collection should raise a StopIteration.
86+
"""
87+
starting_offset = len(entries) + 10
88+
object_collection = self._object_collection_instance(
89+
mock_session,
90+
limit=5,
91+
return_full_pages=False,
92+
starting_pointer=starting_offset
93+
)
94+
with pytest.raises(StopIteration):
95+
object_collection.next()
96+
97+
def test_object_collection_sets_limit_to_returned_value_if_originally_none(self, mock_session):
98+
# No limit specified
99+
object_collection = self._object_collection_instance(mock_session)
100+
object_collection.next()
101+
assert object_collection._limit == self.DEFAULT_LIMIT # pylint:disable=protected-access
102+
103+
def test_object_collection_sets_limit_to_returned_value_if_originally_too_high(self, mock_session):
104+
object_collection = self._object_collection_instance(mock_session, limit=(1 + self.DEFAULT_LIMIT))
105+
object_collection.next()
106+
assert object_collection._limit == self.DEFAULT_LIMIT # pylint:disable=protected-access
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# coding: utf-8
2+
3+
from __future__ import unicode_literals, absolute_import
4+
import json
5+
from mock import Mock, PropertyMock, ANY
6+
import pytest
7+
8+
from boxsdk.network.default_network import DefaultNetworkResponse
9+
from boxsdk.pagination.marker_based_object_collection import MarkerBasedObjectCollection
10+
from boxsdk.session.box_session import BoxResponse, BoxSession
11+
from .box_object_collection_test_base import BoxObjectCollectionTestBase
12+
13+
14+
class TestMarkerBasedObjectCollection(BoxObjectCollectionTestBase):
15+
"""
16+
In order to conveniently mimic marker based paging for the purposes of this test, the markers ('next_marker' in
17+
the response) returned by the mock_session object is always going to be of the convention: "marker_i", where
18+
i is the starting 0-index based offset of the element.
19+
"""
20+
21+
NO_NEXT_MARKER = object()
22+
23+
@pytest.fixture(params=['', None, NO_NEXT_MARKER])
24+
def next_marker_value_for_last_page(self, request):
25+
return request.param
26+
27+
@pytest.fixture()
28+
def mock_items_response(self, entries, next_marker_value_for_last_page):
29+
"""Baseclass override."""
30+
# pylint:disable=redefined-outer-name
31+
def get_response(limit, marker):
32+
mock_box_response = Mock(BoxResponse)
33+
mock_network_response = Mock(DefaultNetworkResponse)
34+
mock_box_response.network_response = mock_network_response
35+
36+
mock_json = {}
37+
# The marker string should be of format: "marker_i", where i is the offset. Parse that out.
38+
# If the marker is None, then begin paging from the start of the entries.
39+
offset = 0
40+
if marker is not None:
41+
offset = int(marker.split('_')[1])
42+
mock_json['entries'] = entries[offset:limit + offset]
43+
44+
# A next_marker is only returned if there are more pages left.
45+
if (offset + limit) < len(entries):
46+
mock_json['next_marker'] = 'marker_{0}'.format(offset + limit)
47+
elif next_marker_value_for_last_page is not self.NO_NEXT_MARKER:
48+
mock_json['next_marker'] = next_marker_value_for_last_page
49+
50+
mock_box_response.json.return_value = mock_json
51+
mock_box_response.content = json.dumps(mock_json).encode()
52+
mock_box_response.status_code = 200
53+
mock_box_response.ok = True
54+
return mock_box_response
55+
return get_response
56+
57+
@pytest.fixture()
58+
def mock_session(self, translator, mock_items_response):
59+
"""Baseclass override."""
60+
mock_box_session = Mock(BoxSession)
61+
type(mock_box_session).translator = PropertyMock(return_value=translator)
62+
63+
def mock_items_side_effect(_, params):
64+
limit = params['limit']
65+
marker = params.get('marker', None)
66+
return mock_items_response(limit, marker)
67+
68+
mock_box_session.get.side_effect = mock_items_side_effect
69+
return mock_box_session
70+
71+
def _object_collection_instance( # pylint:disable=arguments-differ
72+
self,
73+
session,
74+
limit,
75+
return_full_pages=False,
76+
starting_pointer=None,
77+
supports_limit_offset_paging=False
78+
):
79+
"""Baseclass override."""
80+
return MarkerBasedObjectCollection(
81+
session,
82+
'/some/endpoint',
83+
limit=limit,
84+
return_full_pages=return_full_pages,
85+
marker=starting_pointer,
86+
supports_limit_offset_paging=supports_limit_offset_paging,
87+
)
88+
89+
@pytest.mark.parametrize('return_full_pages', (True, False))
90+
def test_object_collection_sets_next_pointer_correctly(self, mock_session, return_full_pages):
91+
object_collection = self._object_collection_instance(mock_session, limit=5, return_full_pages=return_full_pages)
92+
assert object_collection.next_pointer() is None
93+
object_collection.next()
94+
assert object_collection.next_pointer() == 'marker_5'
95+
96+
@pytest.mark.parametrize('supports_limit_offset_paging', (True, False))
97+
def test_object_collection_specifies_marker_param(self, mock_session, supports_limit_offset_paging):
98+
object_collection = self._object_collection_instance(
99+
mock_session,
100+
limit=100,
101+
supports_limit_offset_paging=supports_limit_offset_paging
102+
)
103+
object_collection.next()
104+
105+
# Assert
106+
expected_params = {'limit': 100}
107+
if supports_limit_offset_paging:
108+
expected_params['useMarker'] = True
109+
mock_session.get.assert_called_with(ANY, params=expected_params)

0 commit comments

Comments
 (0)