Merge "Add tests for Cinder volume list pagination"
diff --git a/tempest/api/volume/v2/test_volumes_list.py b/tempest/api/volume/v2/test_volumes_list.py
index 4c774da..d1eb694 100644
--- a/tempest/api/volume/v2/test_volumes_list.py
+++ b/tempest/api/volume/v2/test_volumes_list.py
@@ -14,6 +14,8 @@
 #    License for the specific language governing permissions and limitations
 #    under the License.
 
+from six.moves.urllib import parse
+
 from tempest.api.volume import base
 from tempest import test
 
@@ -87,3 +89,101 @@
 
         _list_details_with_multiple_params()
         _list_details_with_multiple_params(sort_dir='desc')
+
+    def _test_pagination(self, resource, ids=None, limit=1, **kwargs):
+        """Check list pagination functionality for a resource.
+
+        This method requests the list of resources and follows pagination
+        links.
+
+        If an iterable is supplied in ids it will check that all ids are
+        retrieved and that only those are listed, that we will get a next
+        link for an empty page if the number of items is divisible by used
+        limit (this is expected behavior).
+
+        We can specify number of items per request using limit argument.
+        """
+
+        # Get list method for the type of resource from the client
+        client = getattr(self, resource + '_client')
+        method = getattr(client, 'list_' + resource)
+
+        # Include limit in params for list request
+        params = kwargs.pop('params', {})
+        params['limit'] = limit
+
+        # Store remaining items we are expecting from list
+        if ids is not None:
+            remaining = list(ids)
+        else:
+            remaining = None
+
+        # Mark that we are not comming from a next link
+        next = None
+
+        while True:
+            # Get a list page
+            response = method(return_body=True, params=params, **kwargs)
+
+            # If we have to check ids
+            if remaining is not None:
+                # Confirm we receive expected number of elements
+                num_expected = min(len(remaining), limit)
+                self.assertEqual(num_expected, len(response[resource]),
+                                 'Requested %(#expect)d but got %(#received)d '
+                                 % {'#expect': num_expected,
+                                    '#received': len(response[resource])})
+
+                # For each received element
+                for element in response[resource]:
+                    element_id = element['id']
+                    # Check it's one of expected ids
+                    self.assertIn(element_id,
+                                  ids,
+                                  'Id %(id)s is not in expected ids %(ids)s' %
+                                  {'id': element_id, 'ids': ids})
+                    # If not in remaining, we have received it twice
+                    self.assertIn(element_id,
+                                  remaining,
+                                  'Id %s was received twice' % element_id)
+                    # We no longer expect it
+                    remaining.remove(element_id)
+
+            # If we come from a next link check that absolute url is the same
+            # as the one used for this request
+            if next:
+                self.assertEqual(next, response.response['content-location'])
+
+            # Get next from response
+            next = None
+            for link in response.get(resource + '_links', ()):
+                if link['rel'] == 'next':
+                    next = link['href']
+                    break
+
+            # Check if we have next and we shouldn't or the other way around
+            if remaining is not None:
+                if remaining or (num_expected and len(ids) % limit == 0):
+                    self.assertIsNotNone(next, 'Missing link to next page')
+                else:
+                    self.assertIsNone(next, 'Unexpected link to next page')
+
+            # If we can follow to the next page, get params from url to make
+            # request in the form of a relative URL
+            if next:
+                params = parse.urlparse(next).query
+
+            # If cannot follow make sure it's because we have finished
+            else:
+                self.assertListEqual([], remaining or [],
+                                     'No more pages reported, but still '
+                                     'missing ids %s' % remaining)
+                break
+
+    @test.idempotent_id('e9138a2c-f67b-4796-8efa-635c196d01de')
+    def test_volume_list_details_pagination(self):
+        self._test_pagination('volumes', ids=self.volume_id_list, detail=True)
+
+    @test.idempotent_id('af55e775-8e4b-4feb-8719-215c43b0238c')
+    def test_volume_list_pagination(self):
+        self._test_pagination('volumes', ids=self.volume_id_list, detail=False)
diff --git a/tempest/services/volume/json/volumes_client.py b/tempest/services/volume/json/volumes_client.py
index 466e225..b8e3464 100644
--- a/tempest/services/volume/json/volumes_client.py
+++ b/tempest/services/volume/json/volumes_client.py
@@ -15,6 +15,7 @@
 
 import json
 
+import six
 from six.moves.urllib import parse as urllib
 from tempest_lib import exceptions as lib_exc
 
@@ -39,18 +40,57 @@
         """Return the element 'attachment' from input volumes."""
         return volume['attachments'][0]
 
-    def list_volumes(self, detail=False, params=None):
-        """List all the volumes created."""
+    def _ext_get(self, url, key=None, status=200):
+        """Extended get method.
+
+        Retrieves requested url, checks that status is expected status and
+        return a ResponseBody, ResponseBodyList or ResponseBodyData depending
+        on received data's key entry.
+
+        If key is not specified or is None we will return the whole body in a
+        ResponseBody class.
+        """
+
+        resp, body = self.get(url)
+        body = json.loads(body)
+        self.expected_success(status, resp.status)
+
+        if not key:
+            return service_client.ResponseBody(resp, body)
+        elif isinstance(body[key], dict):
+            return service_client.ResponseBody(resp, body[key])
+        elif isinstance(body[key], list):
+            return service_client.ResponseBodyList(resp, body[key])
+
+        return service_client.ResponseBodyData(resp, body[key])
+
+    def _prepare_params(self, params):
+        """Prepares params for use in get or _ext_get methods.
+
+        If params is a string it will be left as it is, but if it's not it will
+        be urlencoded.
+        """
+        if isinstance(params, six.string_types):
+            return params
+        return urllib.urlencode(params)
+
+    def list_volumes(self, detail=False, params=None, return_body=False):
+        """List all the volumes created.
+
+        Params can be a string (must be urlencoded) or a dictionary.
+        If return_body is True then we will return the whole response body in
+        a ResponseBody class, it it's False or has not been specified we will
+        return only the list of volumes in a ResponseBodyList (inherits from
+        list).
+        """
         url = 'volumes'
         if detail:
             url += '/detail'
         if params:
-            url += '?%s' % urllib.urlencode(params)
+            url += '?%s' % self._prepare_params(params)
 
-        resp, body = self.get(url)
-        body = json.loads(body)
-        self.expected_success(200, resp.status)
-        return service_client.ResponseBodyList(resp, body['volumes'])
+        key = None if return_body else 'volumes'
+        return self._ext_get(url, key)
 
     def show_volume(self, volume_id):
         """Returns the details of a single volume."""