From 4ab98f7d8ed78fed497bff85d06c1bdf225a57f1 Mon Sep 17 00:00:00 2001 From: Vladislav Kuzmin Date: Fri, 20 Mar 2015 17:22:13 +0300 Subject: [PATCH] Create endpoint for listing of all uploads id without auth Add new endpoint in refstack's API for listing uploads id, their creation data and cpid. Add pagination for results. Makes it possible to specify number of page in url base_url/v1/results?page= and other filtering parameters. If page not specified, then by default page equals 1. Add unit and functional tests. Change-Id: Ie57df6b4c2607679b5d2746821b6753fc672d7c3 Implement: https://storyboard.openstack.org/#!/story/2000200 Implement: https://github.com/stackforge/refstack/blob/master/specs/proposed/test-record-api.rst --- doc/refstack.md | 8 +- etc/refstack.conf.sample | 23 ++- refstack/api/constants.py | 23 +++ refstack/api/controllers/v1.py | 78 +++++++- refstack/api/utils.py | 123 ++++++++++++ refstack/db/api.py | 18 ++ refstack/db/sqlalchemy/api.py | 38 ++++ refstack/db/sqlalchemy/models.py | 18 +- refstack/opts.py | 6 +- refstack/tests/api/test_api.py | 117 ++++++++++++ refstack/tests/unit/test_api.py | 121 +++++++++++- refstack/tests/unit/test_api_utils.py | 257 ++++++++++++++++++++++++++ refstack/tests/unit/test_db.py | 121 ++++++++++-- 13 files changed, 900 insertions(+), 51 deletions(-) create mode 100644 refstack/api/constants.py create mode 100644 refstack/api/utils.py create mode 100644 refstack/tests/unit/test_api_utils.py diff --git a/doc/refstack.md b/doc/refstack.md index e3609149..200dd0fa 100755 --- a/doc/refstack.md +++ b/doc/refstack.md @@ -89,5 +89,9 @@ gunicorn: - `refstack-api --env REFSTACK_OSLO_CONFIG=/path/to/refstack.conf` -Now available http://localhost:8000/ with JSON response {'Root': 'OK'} -and http://localhost:8000/v1/results/ with JSON response {'Results': 'OK'}. +Now available: + +- http://localhost:8000/ with JSON response {'Root': 'OK'}; +- http://localhost:8000/v1/results with response JSON including records consisted of , and of the test runs. The default response is limited to one page of the most recent uploaded test run records. The number of records per page is configurable via the Refstack configuration file. Filtering parameters such as page, start_date, end_date ... can also be used to specify the desired records. For example: get http://localhost:8000/v1/results?page=n will return page n of the data. + +- http://localhost:8000/v1/results/ with response JSON including the detail test results of the specified diff --git a/etc/refstack.conf.sample b/etc/refstack.conf.sample index 7d2913d1..49a8cf6f 100644 --- a/etc/refstack.conf.sample +++ b/etc/refstack.conf.sample @@ -70,7 +70,7 @@ #logging_exception_prefix = %(asctime)s.%(msecs)03d %(process)d TRACE %(name)s %(instance)s # List of logger=LEVEL pairs. (list value) -#default_log_levels = amqp=WARN,amqplib=WARN,boto=WARN,qpid=WARN,sqlalchemy=WARN,suds=INFO,oslo.messaging=INFO,iso8601=WARN,requests.packages.urllib3.connectionpool=WARN,urllib3.connectionpool=WARN,websocket=WARN,keystonemiddleware=WARN,routes.middleware=WARN,stevedore=WARN +#default_log_levels = amqp=WARN,amqplib=WARN,boto=WARN,qpid=WARN,sqlalchemy=WARN,suds=INFO,oslo.messaging=INFO,iso8601=WARN,requests.packages.urllib3.connectionpool=WARN,urllib3.connectionpool=WARN,websocket=WARN,requests.packages.urllib3.util.retry=WARN,urllib3.util.retry=WARN,keystonemiddleware=WARN,routes.middleware=WARN,stevedore=WARN # Enables or disables publication of error events. (boolean value) #publish_errors = false @@ -122,6 +122,12 @@ # (boolean value) #app_dev_mode = false +# Number of results for one page (integer value) +#results_per_page = 20 + +# The format for start_date and end_date parameters (string value) +#input_date_format = %Y-%m-%d %H:%M:%S + [database] @@ -212,17 +218,18 @@ # lost. (boolean value) #use_db_reconnect = false -# Seconds between database connection retries. (integer value) +# Seconds between retries of a database transaction. (integer value) #db_retry_interval = 1 -# If True, increases the interval between database connection retries -# up to db_max_retry_interval. (boolean value) +# If True, increases the interval between retries of a database +# operation up to db_max_retry_interval. (boolean value) #db_inc_retry_interval = true -# If db_inc_retry_interval is set, the maximum seconds between -# database connection retries. (integer value) +# If db_inc_retry_interval is set, the maximum seconds between retries +# of a database operation. (integer value) #db_max_retry_interval = 10 -# Maximum database connection retries before error is raised. Set to -# -1 to specify an infinite retry count. (integer value) +# Maximum retries in case of connection error or deadlock error before +# error is raised. Set to -1 to specify an infinite retry count. +# (integer value) #db_max_retries = 20 diff --git a/refstack/api/constants.py b/refstack/api/constants.py new file mode 100644 index 00000000..9d2f7ff4 --- /dev/null +++ b/refstack/api/constants.py @@ -0,0 +1,23 @@ +# Copyright (c) 2015 Mirantis, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +""" +Constants for Refstack API +""" + +# Names of input parameters for request +START_DATE = 'start_date' +END_DATE = 'end_date' +CPID = 'cpid' +PAGE = 'page' diff --git a/refstack/api/controllers/v1.py b/refstack/api/controllers/v1.py index 1d035a37..4c75ec11 100644 --- a/refstack/api/controllers/v1.py +++ b/refstack/api/controllers/v1.py @@ -14,17 +14,36 @@ # under the License. """Version 1 of the API.""" +from oslo_config import cfg from oslo_log import log import pecan from pecan import rest from refstack import db +from refstack.api import constants as const +from refstack.api import utils as api_utils from refstack.common import validators LOG = log.getLogger(__name__) +CTRLS_OPTS = [ + cfg.IntOpt('results_per_page', + default=20, + help='Number of results for one page'), + cfg.StrOpt('input_date_format', + default='%Y-%m-%d %H:%M:%S', + help='The format for %(start)s and %(end)s parameters' % { + 'start': const.START_DATE, + 'end': const.END_DATE + }) +] -class RestControllerWithValidation(rest.RestController): +CONF = cfg.CONF + +CONF.register_opts(CTRLS_OPTS, group='api') + + +class BaseRestControllerWithValidation(rest.RestController): """ Controller provides validation for POSTed data @@ -68,7 +87,7 @@ class RestControllerWithValidation(rest.RestController): return item_id -class ResultsController(RestControllerWithValidation): +class ResultsController(BaseRestControllerWithValidation): """/v1/results handler.""" @@ -89,6 +108,61 @@ class ResultsController(RestControllerWithValidation): test_id = db.store_results(item_in_json) return {'test_id': test_id} + @pecan.expose('json') + def get(self): + """ + Get information of all uploaded test results in descending + chronological order. + Make it possible to specify some input parameters + for filtering. + For example: + /v1/results?page=&cpid=1234. + By default, page is set to page number 1, + if the page parameter is not specified. + """ + + expected_input_params = [ + const.START_DATE, + const.END_DATE, + const.CPID, + ] + + try: + filters = api_utils.parse_input_params(expected_input_params) + records_count = db.get_test_records_count(filters) + page_number, total_pages_number = \ + api_utils.get_page_number(records_count) + except api_utils.ParseInputsError as ex: + pecan.abort(400, 'Reason: %s' % ex) + except Exception as ex: + LOG.debug('An error occurred: %s' % ex) + pecan.abort(500) + + try: + per_page = CONF.api.results_per_page + records = db.get_test_records(page_number, per_page, filters) + + results = [] + for r in records: + results.append({ + 'test_id': r.id, + 'created_at': r.created_at, + 'cpid': r.cpid + }) + + page = {} + page['results'] = results + page['pagination'] = { + 'current_page': page_number, + 'total_pages': total_pages_number + } + except Exception as ex: + LOG.debug('An error occurred during ' + 'operation with database: %s' % ex) + pecan.abort(400) + + return page + class V1Controller(object): diff --git a/refstack/api/utils.py b/refstack/api/utils.py new file mode 100644 index 00000000..16a16c7b --- /dev/null +++ b/refstack/api/utils.py @@ -0,0 +1,123 @@ +# Copyright (c) 2015 Mirantis, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Refstack API's utils.""" +import copy + +from oslo_config import cfg +from oslo_log import log +from oslo_utils import timeutils +import pecan + +from refstack.api import constants as const + +LOG = log.getLogger(__name__) +CONF = cfg.CONF + + +class ParseInputsError(Exception): + pass + + +def _get_input_params_from_request(expected_params): + """Get input parameters from request. + + :param expecred_params: (array) Expected input + params specified in constants. + """ + filters = {} + for param in expected_params: + value = pecan.request.GET.get(param) + if value is not None: + filters[param] = value + LOG.debug('Parameter %(param)s has been received ' + 'with value %(value)s' % { + 'param': param, + 'value': value + }) + return filters + + +def parse_input_params(expected_input_params): + """Parse input parameters from request. + + :param expecred_params: (array) Expected input + params specified in constants. + + """ + raw_filters = _get_input_params_from_request(expected_input_params) + filters = copy.deepcopy(raw_filters) + date_fmt = CONF.api.input_date_format + + for key, value in filters.items(): + if key == const.START_DATE or key == const.END_DATE: + try: + filters[key] = timeutils.parse_strtime(value, date_fmt) + except (ValueError, TypeError) as exc: + raise ParseInputsError('Invalid date format: %(exc)s' + % {'exc': exc}) + + start_date = filters.get(const.START_DATE) + end_date = filters.get(const.END_DATE) + if start_date and end_date: + if start_date > end_date: + raise ParseInputsError('Invalid dates: %(start)s ' + 'more than %(end)s' % { + 'start': const.START_DATE, + 'end': const.END_DATE + }) + return filters + + +def _calculate_pages_number(per_page, records_count): + """Return pages number. + :param per_page: (int) results number fot one page. + :param records_count: (int) total records count. + """ + quotient, remainder = divmod(records_count, per_page) + if remainder > 0: + return quotient + 1 + return quotient + + +def get_page_number(records_count): + """Get page number from request + :param records_count: (int) total records count. + """ + page_number = pecan.request.GET.get(const.PAGE) + per_page = CONF.api.results_per_page + + total_pages = _calculate_pages_number(per_page, records_count) + # The first page exists in any case + if page_number is None: + return (1, total_pages) + try: + page_number = int(page_number) + except (ValueError, TypeError): + raise ParseInputsError('Invalid page number: The page number can not ' + 'be converted to an integer') + + if page_number == 1: + return (page_number, total_pages) + + if page_number <= 0: + raise ParseInputsError('Invalid page number: ' + 'The page number less or equal zero.') + + if page_number > total_pages: + raise ParseInputsError('Invalid page number: The page number ' + 'is greater than the total number of pages.') + + return (page_number, total_pages) diff --git a/refstack/db/api.py b/refstack/db/api.py index 2e3ebde6..9e6d8ed1 100644 --- a/refstack/db/api.py +++ b/refstack/db/api.py @@ -61,3 +61,21 @@ def get_test_results(test_id): :param test_id: The ID of the test. """ return IMPL.get_test_results(test_id) + + +def get_test_records(page_number, per_page, filters): + """Get page with applied filters for uploaded test records. + + :param page_number: The number of page. + :param per_page: The number of results for one page. + :param filters: (Dict) Filters that will be applied for records. + """ + return IMPL.get_test_records(page_number, per_page, filters) + + +def get_test_records_count(filters): + """Get total pages number with applied filters for uploaded test records. + + :param filters: (Dict) Filters that will be applied for records. + """ + return IMPL.get_test_records_count(filters) diff --git a/refstack/db/sqlalchemy/api.py b/refstack/db/sqlalchemy/api.py index c475d79a..4f02f3dd 100644 --- a/refstack/db/sqlalchemy/api.py +++ b/refstack/db/sqlalchemy/api.py @@ -22,6 +22,7 @@ from oslo_config import cfg from oslo_db import options as db_options from oslo_db.sqlalchemy import session as db_session +from refstack.api import constants as api_const from refstack.db.sqlalchemy import models @@ -92,3 +93,40 @@ def get_test_results(test_id): filter_by(test_id=test_id).\ all() return results + + +def _apply_filters_for_query(query, filters): + start_date = filters.get(api_const.START_DATE) + if start_date: + query = query.filter(models.Test.created_at >= start_date) + + end_date = filters.get(api_const.END_DATE) + if end_date: + query = query.filter(models.Test.created_at <= end_date) + + cpid = filters.get(api_const.CPID) + if cpid: + query = query.filter(models.Test.cpid == cpid) + + return query + + +def get_test_records(page, per_page, filters): + session = get_session() + query = session.query(models.Test.id, + models.Test.created_at, + models.Test.cpid) + + query = _apply_filters_for_query(query, filters) + results = query.order_by(models.Test.created_at.desc()).\ + offset(per_page * (page - 1)).\ + limit(per_page) + return results + + +def get_test_records_count(filters): + session = get_session() + query = session.query(models.Test.id) + records_count = _apply_filters_for_query(query, filters).count() + + return records_count diff --git a/refstack/db/sqlalchemy/models.py b/refstack/db/sqlalchemy/models.py index 204618aa..6d7d2061 100644 --- a/refstack/db/sqlalchemy/models.py +++ b/refstack/db/sqlalchemy/models.py @@ -17,11 +17,8 @@ SQLAlchemy models for Refstack data. """ -import datetime - from oslo_config import cfg from oslo_db.sqlalchemy import models -from oslo_utils import timeutils import sqlalchemy as sa from sqlalchemy import orm from sqlalchemy.ext.declarative import declarative_base @@ -30,24 +27,15 @@ CONF = cfg.CONF BASE = declarative_base() -class RefStackBase(models.ModelBase, models.TimestampMixin): +class RefStackBase(models.ModelBase, + models.TimestampMixin, + models.SoftDeleteMixin): """Base class for RefStack Models.""" __table_args__ = {'mysql_engine': 'InnoDB'} - created_at = sa.Column(sa.DateTime(), default=datetime.datetime.utcnow, - nullable=False) - updated_at = sa.Column(sa.DateTime()) - deleted_at = sa.Column(sa.DateTime) - deleted = sa.Column(sa.Integer, default=0) metadata = None - def delete(self, session=None): - """Delete this object.""" - self.deleted = self.id - self.deleted_at = timeutils.utcnow() - self.save(session=session) - class Test(BASE, RefStackBase): diff --git a/refstack/opts.py b/refstack/opts.py index 2e0f5f51..bb0110a0 100644 --- a/refstack/opts.py +++ b/refstack/opts.py @@ -31,7 +31,10 @@ ] ... """ +import itertools + import refstack.api.app +import refstack.api.controllers.v1 import refstack.db.api @@ -39,5 +42,6 @@ def list_opts(): return [ # Keep a list in alphabetical order ('DEFAULT', refstack.db.api.db_opts), - ('api', refstack.api.app.API_OPTS), + ('api', itertools.chain(refstack.api.app.API_OPTS, + refstack.api.controllers.v1.CTRLS_OPTS)), ] diff --git a/refstack/tests/api/test_api.py b/refstack/tests/api/test_api.py index af1b7a23..5eb1eed4 100644 --- a/refstack/tests/api/test_api.py +++ b/refstack/tests/api/test_api.py @@ -18,6 +18,7 @@ import json import uuid +from oslo_config import fixture as config_fixture import six import webtest.app @@ -52,6 +53,11 @@ class TestResultsController(api.FunctionalTest): URL = '/v1/results/' + def setUp(self): + super(TestResultsController, self).setUp() + self.config_fixture = config_fixture.Config() + self.CONF = self.useFixture(self.config_fixture).conf + def test_post(self): """Test results endpoint with post request.""" results = json.dumps(FAKE_TESTS_RESULT) @@ -103,3 +109,114 @@ class TestResultsController(api.FunctionalTest): self.assertRaises(webtest.app.AppError, self.get_json, self.URL + 'fake_url') + + def test_get_pagination(self): + self.CONF.set_override('results_per_page', + 2, + 'api') + + responses = [] + for i in range(3): + fake_results = { + 'cpid': six.text_type(i), + 'duration_seconds': i, + 'results': [ + {'name': 'tempest.foo.bar'}, + {'name': 'tempest.buzz'} + ] + } + actual_response = self.post_json(self.URL, + params=json.dumps(fake_results)) + responses.append(actual_response) + + page_one = self.get_json(self.URL) + page_two = self.get_json('/v1/results?page=2') + + self.assertEqual(len(page_one['results']), 2) + self.assertEqual(len(page_two['results']), 1) + self.assertNotIn(page_two['results'][0], page_one) + + self.assertEqual(page_one['pagination']['current_page'], 1) + self.assertEqual(page_one['pagination']['total_pages'], 2) + + self.assertEqual(page_two['pagination']['current_page'], 2) + self.assertEqual(page_two['pagination']['total_pages'], 2) + + def test_get_with_not_existing_page(self): + self.assertRaises(webtest.app.AppError, + self.get_json, + '/v1/results?page=2') + + def test_get_with_empty_database(self): + results = self.get_json(self.URL) + self.assertEqual(results, []) + + def test_get_with_cpid_filter(self): + self.CONF.set_override('results_per_page', + 2, + 'api') + + responses = [] + for i in range(2): + fake_results = { + 'cpid': '12345', + 'duration_seconds': i, + 'results': [ + {'name': 'tempest.foo'}, + {'name': 'tempest.bar'} + ] + } + json_result = json.dumps(fake_results) + actual_response = self.post_json(self.URL, + params=json_result) + responses.append(actual_response) + + for i in range(3): + fake_results = { + 'cpid': '54321', + 'duration_seconds': i, + 'results': [ + {'name': 'tempest.foo'}, + {'name': 'tempest.bar'} + ] + } + + results = self.get_json('/v1/results?page=1&cpid=12345') + self.asserEqual(len(results), 2) + + for r in results: + self.assertIn(r['test_id'], responses) + + def test_get_with_date_filters(self): + self.CONF.set_override('results_per_page', + 10, + 'api') + + responses = [] + for i in range(5): + fake_results = { + 'cpid': '12345', + 'duration_seconds': i, + 'results': [ + {'name': 'tempest.foo'}, + {'name': 'tempest.bar'} + ] + } + json_result = json.dumps(fake_results) + actual_response = self.post_json(self.URL, + params=json_result) + responses.append(actual_response) + + all_results = self.get_json(self.URL) + + slice_results = all_results[1:3] + + url = 'v1/results?start_date=%(start)s&end_date=%(end)s' % { + 'start': slice_results[2]['created_at'], + 'end': slice_results[0]['created_at'] + } + + filtering_results = self.get_json(url) + self.assertEqual(len(filtering_results), 3) + for r in slice_results: + self.assertEqual(r, filtering_results) diff --git a/refstack/tests/unit/test_api.py b/refstack/tests/unit/test_api.py index b4d84914..bd715d30 100644 --- a/refstack/tests/unit/test_api.py +++ b/refstack/tests/unit/test_api.py @@ -16,8 +16,11 @@ """Tests for API's controllers""" import mock +from oslo_config import fixture as config_fixture from oslotest import base +from refstack.api import constants as const +from refstack.api import utils as api_utils from refstack.api.controllers import root from refstack.api.controllers import v1 @@ -36,6 +39,8 @@ class ResultsControllerTestCase(base.BaseTestCase): super(ResultsControllerTestCase, self).setUp() self.validator = mock.Mock() self.controller = v1.ResultsController(self.validator) + self.config_fixture = config_fixture.Config() + self.CONF = self.useFixture(self.config_fixture).conf @mock.patch('refstack.db.get_test') @mock.patch('refstack.db.get_test_results') @@ -115,13 +120,123 @@ class ResultsControllerTestCase(base.BaseTestCase): self.assertEqual(result, {'test_id': 'fake_result'}) mock_store_item.assert_called_once_with('fake_item') + @mock.patch('pecan.abort') + @mock.patch('refstack.api.utils.parse_input_params') + def test_get_failed_in_parse_input_params(self, + parse_inputs, + pecan_abort): -class RestControllerWithValidationTestCase(base.BaseTestCase): + parse_inputs.side_effect = api_utils.ParseInputsError() + pecan_abort.side_effect = Exception() + self.assertRaises(Exception, + self.controller.get) + + @mock.patch('refstack.db.get_test_records_count') + @mock.patch('pecan.abort') + @mock.patch('refstack.api.utils.parse_input_params') + def test_get_failed_in_get_test_records_number(self, + parse_inputs, + pecan_abort, + db_get_count): + db_get_count.side_effect = Exception() + pecan_abort.side_effect = Exception() + self.assertRaises(Exception, + self.controller.get) + + @mock.patch('refstack.db.get_test_records_count') + @mock.patch('refstack.api.utils.parse_input_params') + @mock.patch('refstack.api.utils.get_page_number') + @mock.patch('pecan.abort') + def test_get_failed_in_get_page_number(self, + pecan_abort, + get_page, + parse_input, + db_get_count): + + get_page.side_effect = api_utils.ParseInputsError() + pecan_abort.side_effect = Exception() + self.assertRaises(Exception, + self.controller.get) + + @mock.patch('refstack.db.get_test_records') + @mock.patch('refstack.db.get_test_records_count') + @mock.patch('refstack.api.utils.parse_input_params') + @mock.patch('refstack.api.utils.get_page_number') + @mock.patch('pecan.abort') + def test_get_failed_in_get_test_records(self, + pecan_abort, + get_page, + parce_input, + db_get_count, + db_get_test): + + get_page.return_value = (mock.Mock(), mock.Mock()) + db_get_test.side_effect = Exception() + pecan_abort.side_effect = Exception() + self.assertRaises(Exception, + self.controller.get) + + @mock.patch('refstack.db.get_test_records') + @mock.patch('refstack.db.get_test_records_count') + @mock.patch('refstack.api.utils.get_page_number') + @mock.patch('refstack.api.utils.parse_input_params') + def test_get_success(self, + parse_input, + get_page, + get_test_count, + db_get_test): + + expected_input_params = [ + const.START_DATE, + const.END_DATE, + const.CPID, + ] + page_number = 1 + total_pages_number = 10 + per_page = 5 + records_count = 50 + get_test_count.return_value = records_count + get_page.return_value = (page_number, total_pages_number) + self.CONF.set_override('results_per_page', + per_page, + 'api') + + record = mock.Mock() + record.id = 111 + record.created_at = '12345' + record.cpid = '54321' + + db_get_test.return_value = [record] + expected_result = { + 'results': [{ + 'test_id': record.id, + 'created_at': record.created_at, + 'cpid': record.cpid + }], + 'pagination': { + 'current_page': page_number, + 'total_pages': total_pages_number + } + } + + actual_result = self.controller.get() + self.assertEqual(actual_result, expected_result) + + parse_input.assert_called_once_with(expected_input_params) + + filters = parse_input.return_value + get_test_count.assert_called_once_with(filters) + get_page.assert_called_once_with(records_count) + + db_get_test.assert_called_once_with(page_number, per_page, filters) + + +class BaseRestControllerWithValidationTestCase(base.BaseTestCase): def setUp(self): - super(RestControllerWithValidationTestCase, self).setUp() + super(BaseRestControllerWithValidationTestCase, self).setUp() self.validator = mock.Mock() - self.controller = v1.RestControllerWithValidation(self.validator) + self.controller = v1.BaseRestControllerWithValidation(self.validator) @mock.patch('pecan.response') @mock.patch('refstack.common.validators.safe_load_json_body') diff --git a/refstack/tests/unit/test_api_utils.py b/refstack/tests/unit/test_api_utils.py new file mode 100644 index 00000000..35151cb4 --- /dev/null +++ b/refstack/tests/unit/test_api_utils.py @@ -0,0 +1,257 @@ +# Copyright (c) 2015 Mirantis, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Tests for API's utils""" + +import mock +from oslo_config import fixture as config_fixture +from oslo_utils import timeutils +from oslotest import base + +from refstack.api import constants as const +from refstack.api import utils as api_utils + + +class APIUtilsTestCase(base.BaseTestCase): + + def setUp(self): + super(APIUtilsTestCase, self).setUp() + self.config_fixture = config_fixture.Config() + self.CONF = self.useFixture(self.config_fixture).conf + + @mock.patch('pecan.request') + def test_get_input_params_from_request_all_results(self, mock_request): + received_params = { + const.START_DATE: '2015-03-26 15:04:40', + const.END_DATE: '2015-03-26 15:04:45', + const.CPID: '12345', + } + + expected_params = [ + const.START_DATE, + const.END_DATE, + const.CPID + ] + + mock_request.GET = received_params + + result = api_utils._get_input_params_from_request(expected_params) + + self.assertEqual(result, received_params) + + @mock.patch('pecan.request') + def test_get_input_params_from_request_partial_results(self, + mock_request): + received_params = { + const.START_DATE: '2015-03-26 15:04:40', + const.END_DATE: '2015-03-26 15:04:45', + const.CPID: '12345', + } + + expected_params = [ + const.START_DATE, + const.END_DATE, + ] + + expected_results = { + const.START_DATE: '2015-03-26 15:04:40', + const.END_DATE: '2015-03-26 15:04:45', + } + + mock_request.GET = received_params + + result = api_utils._get_input_params_from_request(expected_params) + + self.assertEqual(result, expected_results) + + @mock.patch('oslo_utils.timeutils.parse_strtime') + @mock.patch.object(api_utils, '_get_input_params_from_request') + def test_parse_input_params_failed_in_parse_time(self, mock_get_input, + mock_strtime): + fmt = '%Y-%m-%d %H:%M:%S' + self.CONF.set_override('input_date_format', + fmt, + 'api') + raw_filters = { + const.START_DATE: '2015-03-26 15:04:40', + const.END_DATE: '2015-03-26 15:04:45', + const.CPID: '12345', + } + + expected_params = mock.Mock() + mock_get_input.return_value = raw_filters + mock_strtime.side_effect = ValueError() + self.assertRaises(api_utils.ParseInputsError, + api_utils.parse_input_params, + expected_params) + + @mock.patch.object(api_utils, '_get_input_params_from_request') + def test_parse_input_params_failed_in_compare_date(self, mock_get_input): + fmt = '%Y-%m-%d %H:%M:%S' + self.CONF.set_override('input_date_format', + fmt, + 'api') + raw_filters = { + const.START_DATE: '2015-03-26 15:04:50', + const.END_DATE: '2015-03-26 15:04:40', + const.CPID: '12345', + } + + expected_params = mock.Mock() + mock_get_input.return_value = raw_filters + self.assertRaises(api_utils.ParseInputsError, + api_utils.parse_input_params, + expected_params) + + @mock.patch.object(api_utils, '_get_input_params_from_request') + def test_parse_input_params_success(self, mock_get_input): + fmt = '%Y-%m-%d %H:%M:%S' + self.CONF.set_override('input_date_format', + fmt, + 'api') + raw_filters = { + const.START_DATE: '2015-03-26 15:04:40', + const.END_DATE: '2015-03-26 15:04:50', + const.CPID: '12345', + } + + expected_params = mock.Mock() + mock_get_input.return_value = raw_filters + + parsed_start_date = timeutils.parse_strtime( + raw_filters[const.START_DATE], + fmt + ) + + parsed_end_date = timeutils.parse_strtime( + raw_filters[const.END_DATE], + fmt + ) + + expected_result = { + const.START_DATE: parsed_start_date, + const.END_DATE: parsed_end_date, + const.CPID: '12345' + } + + result = api_utils.parse_input_params(expected_params) + self.assertEqual(result, expected_result) + + mock_get_input.assert_called_once_with(expected_params) + + def test_calculate_pages_number_full_pages(self): + # expected pages number: 20/10 = 2 + page_number = api_utils._calculate_pages_number(10, 20) + self.assertEqual(page_number, 2) + + def test_calculate_pages_number_half_page(self): + # expected pages number: 25/10 + # => quotient == 2 and remainder == 5 + # => total number of pages == 3 + page_number = api_utils._calculate_pages_number(10, 25) + self.assertEqual(page_number, 3) + + @mock.patch('pecan.request') + def test_get_page_number_page_number_is_none(self, mock_request): + per_page = 20 + total_records = 100 + self.CONF.set_override('results_per_page', + per_page, + 'api') + mock_request.GET = { + const.PAGE: None + } + + page_number, total_pages = api_utils.get_page_number(total_records) + + self.assertEqual(page_number, 1) + self.assertEqual(total_pages, total_records / per_page) + + @mock.patch('pecan.request') + def test_get_page_number_page_number_not_int(self, mock_request): + per_page = 20 + total_records = 100 + self.CONF.set_override('results_per_page', + per_page, + 'api') + mock_request.GET = { + const.PAGE: 'abc' + } + + self.assertRaises(api_utils.ParseInputsError, + api_utils.get_page_number, + total_records) + + @mock.patch('pecan.request') + def test_get_page_number_page_number_is_one(self, mock_request): + per_page = 20 + total_records = 100 + self.CONF.set_override('results_per_page', + per_page, + 'api') + mock_request.GET = { + const.PAGE: '1' + } + + page_number, total_pages = api_utils.get_page_number(total_records) + + self.assertEqual(page_number, 1) + self.assertEqual(total_pages, total_records / per_page) + + @mock.patch('pecan.request') + def test_get_page_number_page_number_less_zero(self, mock_request): + per_page = 20 + total_records = 100 + self.CONF.set_override('results_per_page', + per_page, + 'api') + mock_request.GET = { + const.PAGE: '-1' + } + + self.assertRaises(api_utils.ParseInputsError, + api_utils.get_page_number, + total_records) + + @mock.patch('pecan.request') + def test_get_page_number_page_number_more_than_total(self, mock_request): + per_page = 20 + total_records = 100 + self.CONF.set_override('results_per_page', + per_page, + 'api') + mock_request.GET = { + const.PAGE: '100' + } + + self.assertRaises(api_utils.ParseInputsError, + api_utils.get_page_number, + total_records) + + @mock.patch('pecan.request') + def test_get_page_number_success(self, mock_request): + per_page = 20 + total_records = 100 + self.CONF.set_override('results_per_page', + per_page, + 'api') + mock_request.GET = { + const.PAGE: '2' + } + + page_number, total_pages = api_utils.get_page_number(total_records) + + self.assertEqual(page_number, 2) + self.assertEqual(total_pages, total_records / per_page) diff --git a/refstack/tests/unit/test_db.py b/refstack/tests/unit/test_db.py index 2cb8ec4c..3c97a26e 100644 --- a/refstack/tests/unit/test_db.py +++ b/refstack/tests/unit/test_db.py @@ -17,30 +17,12 @@ import six import mock +from oslo_config import fixture as config_fixture from oslotest import base from refstack import db +from refstack.api import constants as api_const from refstack.db.sqlalchemy import api -from refstack.db.sqlalchemy import models - - -class RefStackBaseTestCase(base.BaseTestCase): - """Test case for RefStackBase model.""" - - @mock.patch('oslo_utils.timeutils.utcnow') - def test_delete(self, utcnow): - utcnow.return_value = '123' - - base_model = models.RefStackBase() - base_model.id = 'fake_id' - base_model.save = mock.Mock() - session = mock.MagicMock() - - base_model.delete(session) - - self.assertEqual(base_model.deleted, 'fake_id') - self.assertEqual(base_model.deleted_at, '123') - base_model.save.assert_called_once_with(session=session) class DBAPITestCase(base.BaseTestCase): @@ -61,6 +43,18 @@ class DBAPITestCase(base.BaseTestCase): db.get_test_results(12345) mock_get_test_results.assert_called_once_with(12345) + @mock.patch.object(api, 'get_test_records') + def test_get_test_records(self, mock_db): + filters = mock.Mock() + db.get_test_records(1, 2, filters) + mock_db.assert_called_once_with(1, 2, filters) + + @mock.patch.object(api, 'get_test_records_count') + def test_get_test_records_count(self, mock_db): + filters = mock.Mock() + db.get_test_records_count(filters) + mock_db.assert_called_once_with(filters) + class DBHelpersTestCase(base.BaseTestCase): """Test case for database backend helpers.""" @@ -97,6 +91,11 @@ class DBHelpersTestCase(base.BaseTestCase): class DBBackendTestCase(base.BaseTestCase): """Test case for database backend.""" + def setUp(self): + super(DBBackendTestCase, self).setUp() + self.config_fixture = config_fixture.Config() + self.CONF = self.useFixture(self.config_fixture).conf + @mock.patch.object(api, 'get_session') @mock.patch('refstack.db.sqlalchemy.models.TestResults') @mock.patch('refstack.db.sqlalchemy.models.Test') @@ -181,3 +180,85 @@ class DBBackendTestCase(base.BaseTestCase): query.filter_by.assert_called_once_with(test_id=test_id) filter_by.all.assert_called_once_with() self.assertEqual(expected_result, actual_result) + + @mock.patch('refstack.db.sqlalchemy.models.Test') + def test_apply_filters_for_query(self, mock_model): + query = mock.Mock() + mock_model.created_at = six.text_type() + + filters = { + api_const.START_DATE: 'fake1', + api_const.END_DATE: 'fake2', + api_const.CPID: 'fake3' + } + + result = api._apply_filters_for_query(query, filters) + + query.filter.assert_called_once_with(mock_model.created_at >= + filters[api_const.START_DATE]) + + query = query.filter.return_value + query.filter.assert_called_once_with(mock_model.created_at <= + filters[api_const.END_DATE]) + + query = query.filter.return_value + query.filter.assert_called_once_with(mock_model.cpid == + filters[api_const.CPID]) + + query = query.filter.return_value + self.assertEqual(result, query) + + @mock.patch.object(api, '_apply_filters_for_query') + @mock.patch.object(api, 'get_session') + @mock.patch('refstack.db.sqlalchemy.models.Test') + def test_get_test_records(self, mock_model, + mock_get_session, + mock_apply): + + per_page = 9000 + filters = { + api_const.START_DATE: 'fake1', + api_const.END_DATE: 'fake2', + api_const.CPID: 'fake3' + } + + session = mock_get_session.return_value + first_query = session.query.return_value + second_query = mock_apply.return_value + ordered_query = second_query.order_by.return_value + query_with_offset = ordered_query.offset.return_value + query_with_offset.limit.return_value = 'fake_uploads' + + result = api.get_test_records(2, per_page, filters) + + mock_get_session.assert_called_once_with() + session.query.assert_called_once_with(mock_model.id, + mock_model.created_at, + mock_model.cpid) + mock_apply.assert_called_once_with(first_query, filters) + second_query.order_by.\ + assert_called_once_with(mock_model.created_at.desc()) + + self.assertEqual(result, 'fake_uploads') + ordered_query.offset.assert_called_once_with(per_page) + query_with_offset.limit.assert_called_once_with(per_page) + + @mock.patch.object(api, '_apply_filters_for_query') + @mock.patch.object(api, 'get_session') + @mock.patch('refstack.db.sqlalchemy.models.Test') + def test_get_test_records_count(self, mock_model, + mock_get_session, + mock_apply): + + filters = mock.Mock() + session = mock_get_session.return_value + query = session.query.return_value + apply_result = mock_apply.return_value + apply_result.count.return_value = 999 + + result = api.get_test_records_count(filters) + self.assertEqual(result, 999) + + session.query.assert_called_once_with(mock_model.id) + mock_apply.assert_called_once_with(query, filters) + apply_result.count.assert_called_once_with()