diff --git a/awx/api/serializers.py b/awx/api/serializers.py index e9d34c64d7..04e6f59134 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1565,6 +1565,14 @@ class JobOptionsSerializer(BaseSerializer): args=(obj.cloud_credential.pk,)) return res + def _summary_field_labels(self, obj): + return [{'id': x.id, 'name': x.name} for x in obj.labels.all().order_by('-name')[:10]] + + def get_summary_fields(self, obj): + res = super(JobOptionsSerializer, self).get_summary_fields(obj) + res['labels'] = self._summary_field_labels(obj) + return res + def to_representation(self, obj): ret = super(JobOptionsSerializer, self).to_representation(obj) if obj is None: @@ -1622,6 +1630,9 @@ class JobTemplateSerializer(UnifiedJobTemplateSerializer, JobOptionsSerializer): res['callback'] = reverse('api:job_template_callback', args=(obj.pk,)) return res + def _recent_jobs(self, obj): + return [{'id': x.id, 'status': x.status, 'finished': x.finished} for x in obj.jobs.filter(active=True).order_by('-created')[:10]] + def get_summary_fields(self, obj): d = super(JobTemplateSerializer, self).get_summary_fields(obj) if obj.survey_spec is not None and ('name' in obj.survey_spec and 'description' in obj.survey_spec): @@ -1640,8 +1651,7 @@ class JobTemplateSerializer(UnifiedJobTemplateSerializer, JobOptionsSerializer): else: d['can_copy'] = False d['can_edit'] = False - d['recent_jobs'] = [{'id': x.id, 'status': x.status, 'finished': x.finished} for x in obj.jobs.order_by('-created')[:10]] - d['labels'] = [{'id': x.id, 'name': x.name} for x in obj.labels.all().order_by('-name')[:10]] + d['recent_jobs'] = self._recent_jobs(obj) return d def validate(self, attrs): @@ -1683,11 +1693,6 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer): res['relaunch'] = reverse('api:job_relaunch', args=(obj.pk,)) return res - def get_summary_fields(self, obj): - d = super(JobSerializer, self).get_summary_fields(obj) - d['labels'] = [{'id': x.id, 'name': x.name} for x in obj.labels.all().order_by('-name')[:10]] - return d - def to_internal_value(self, data): # When creating a new job and a job template is specified, populate any # fields not provided in data from the job template. diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index 04ccd5d528..405f3fa0e8 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -400,14 +400,16 @@ def fact_services_json(): def permission_inv_read(organization, inventory, team): return Permission.objects.create(inventory=inventory, team=team, permission_type=PERM_INVENTORY_READ) - @pytest.fixture -def job_template_labels(organization): +def job_template(organization): jt = JobTemplate(name='test-job_template') jt.save() - jt.labels.create(name="label-1", organization=organization) - jt.labels.create(name="label-2", organization=organization) - return jt +@pytest.fixture +def job_template_labels(organization, job_template): + job_template.labels.create(name="label-1", organization=organization) + job_template.labels.create(name="label-2", organization=organization) + + return job_template diff --git a/awx/main/tests/unit/api/test_generics.py b/awx/main/tests/unit/api/test_generics.py new file mode 100644 index 0000000000..42fe141c49 --- /dev/null +++ b/awx/main/tests/unit/api/test_generics.py @@ -0,0 +1,80 @@ + +# Python +import pytest + +# DRF +from rest_framework import status +from rest_framework.response import Response + +# AWX +from awx.api.generics import ParentMixin, SubListCreateAttachDetachAPIView + +@pytest.fixture +def get_object_or_404(mocker): + # pytest patch without return_value generates a random value, we are counting on this + return mocker.patch('awx.api.generics.get_object_or_404') + +@pytest.fixture +def get_object_or_400(mocker): + return mocker.patch('awx.api.generics.get_object_or_400') + +@pytest.fixture +def mock_response_new(mocker): + m = mocker.patch('awx.api.generics.Response.__new__') + m.return_value = m + return m + +@pytest.fixture +def parent_relationship_factory(mocker): + def rf(serializer_class, relationship_name, relationship_value=mocker.Mock()): + mock_parent_relationship = mocker.MagicMock(**{'%s.add.return_value' % relationship_name: relationship_value}) + mocker.patch('awx.api.generics.ParentMixin.get_parent_object', return_value=mock_parent_relationship) + + serializer = serializer_class() + [setattr(serializer, x, '') for x in ['relationship', 'model', 'parent_model']] + serializer.relationship = relationship_name + + return (serializer, mock_parent_relationship) + return rf + +# TODO: Test create and associate failure (i.e. id doesn't exist or record already exists) +# TODO: Mock and check return (Response) +class TestSubListCreateAttachDetachAPIView: + def test_attach_create_and_associate(self, mocker, get_object_or_400, parent_relationship_factory, mock_response_new): + (serializer, mock_parent_relationship) = parent_relationship_factory(SubListCreateAttachDetachAPIView, 'wife') + create_return_value = mocker.MagicMock(status_code=status.HTTP_201_CREATED) + serializer.create = mocker.Mock(return_value=create_return_value) + + mock_request = mocker.MagicMock(data=dict()) + ret = serializer.attach(mock_request, None, None) + + assert ret == mock_response_new + serializer.create.assert_called_with(mock_request, None, None) + mock_parent_relationship.wife.add.assert_called_with(get_object_or_400.return_value) + mock_response_new.assert_called_with(Response, create_return_value.data, status=status.HTTP_201_CREATED, headers={'Location': create_return_value['Location']}) + + def test_attach_associate_only(self, mocker, get_object_or_400, parent_relationship_factory, mock_response_new): + (serializer, mock_parent_relationship) = parent_relationship_factory(SubListCreateAttachDetachAPIView, 'wife') + serializer.create = mocker.Mock(return_value=mocker.MagicMock()) + + mock_request = mocker.MagicMock(data=dict(id=1)) + ret = serializer.attach(mock_request, None, None) + + assert ret == mock_response_new + serializer.create.assert_not_called() + mock_parent_relationship.wife.add.assert_called_with(get_object_or_400.return_value) + mock_response_new.assert_called_with(Response, status=status.HTTP_204_NO_CONTENT) + +class TestParentMixin: + def test_get_parent_object(self, mocker, get_object_or_404): + parent_mixin = ParentMixin() + parent_mixin.lookup_field = 'foo' + parent_mixin.kwargs = dict(foo='bar') + parent_mixin.parent_model = 'parent_model' + mock_parent_mixin = mocker.MagicMock(wraps=parent_mixin) + + return_value = mock_parent_mixin.get_parent_object() + + get_object_or_404.assert_called_with(parent_mixin.parent_model, **parent_mixin.kwargs) + assert get_object_or_404.return_value == return_value + diff --git a/awx/main/tests/unit/api/test_serializers.py b/awx/main/tests/unit/api/test_serializers.py new file mode 100644 index 0000000000..3cac6a34d8 --- /dev/null +++ b/awx/main/tests/unit/api/test_serializers.py @@ -0,0 +1,156 @@ +# Python +import pytest +import mock + +# AWX +from awx.api.serializers import JobTemplateSerializer, JobSerializer, JobOptionsSerializer +from awx.main.models import Label, Job + +@pytest.fixture +def job_template(mocker): + return mocker.MagicMock(pk=5) + +@pytest.fixture +def job(mocker, job_template): + return mocker.MagicMock(pk=5, job_template=job_template) + +@pytest.fixture +def labels(mocker): + return [Label(id=x, name='label-%d' % x) for x in xrange(0, 25)] + +@pytest.fixture +def jobs(mocker): + return [Job(id=x, name='job-%d' % x) for x in xrange(0, 25)] + +class GetRelatedMixin: + def _assert(self, model_obj, related, resource_name, related_resource_name): + assert related_resource_name in related + assert related[related_resource_name] == '/api/v1/%s/%d/%s/' % (resource_name, model_obj.pk, related_resource_name) + + def _mock_and_run(self, serializer_class, model_obj): + serializer = serializer_class() + related = serializer.get_related(model_obj) + return related + + def _test_get_related(self, serializer_class, model_obj, resource_name, related_resource_name): + related = self._mock_and_run(serializer_class, model_obj) + self._assert(model_obj, related, resource_name, related_resource_name) + return related + +class GetSummaryFieldsMixin: + def _assert(self, summary, summary_field_name): + assert summary_field_name in summary + + def _mock_and_run(self, serializer_class, model_obj): + serializer = serializer_class() + return serializer.get_summary_fields(model_obj) + + def _test_get_summary_fields(self, serializer_class, model_obj, summary_field_name): + summary = self._mock_and_run(serializer_class, model_obj) + self._assert(summary, summary_field_name) + return summary + +@mock.patch('awx.api.serializers.UnifiedJobTemplateSerializer.get_related', lambda x,y: {}) +@mock.patch('awx.api.serializers.JobOptionsSerializer.get_related', lambda x,y: {}) +class TestJobTemplateSerializerGetRelated(GetRelatedMixin): + @pytest.mark.parametrize("related_resource_name", [ + 'jobs', + 'schedules', + 'activity_stream', + 'launch', + 'notifiers_any', + 'notifiers_success', + 'notifiers_error', + 'survey_spec', + 'labels', + 'callback', + ]) + def test_get_related(self, job_template, related_resource_name): + self._test_get_related(JobTemplateSerializer, job_template, 'job_templates', related_resource_name) + + def test_callback_absent(self, job_template): + job_template.host_config_key = None + related = self._mock_and_run(JobTemplateSerializer, job_template) + assert 'callback' not in related + +class TestJobTemplateSerializerGetSummaryFields(GetSummaryFieldsMixin): + def test__recent_jobs(self, mocker, job_template, jobs): + + job_template.jobs.filter = mocker.MagicMock(**{'order_by.return_value': jobs}) + job_template.jobs.filter.return_value = job_template.jobs.filter + + serializer = JobTemplateSerializer() + recent_jobs = serializer._recent_jobs(job_template) + + job_template.jobs.filter.assert_called_with(active=True) + job_template.jobs.filter.order_by.assert_called_with('-created') + assert len(recent_jobs) == 10 + for x in jobs[:10]: + assert recent_jobs == [{'id': x.id, 'status': x.status, 'finished': x.finished} for x in jobs[:10]] + + def test_survey_spec_exists(self, mocker, job_template): + job_template.survey_spec = {'name': 'blah', 'description': 'blah blah'} + self._test_get_summary_fields(JobTemplateSerializer, job_template, 'survey') + + def test_survey_spec_absent(self, mocker, job_template): + job_template.survey_spec = None + summary = self._mock_and_run(JobTemplateSerializer, job_template) + assert 'survey' not in summary + + @pytest.mark.skip(reason="RBAC needs to land") + def test_can_copy_true(self, mocker, job_template): + pass + + @pytest.mark.skip(reason="RBAC needs to land") + def test_can_copy_false(self, mocker, job_template): + pass + + @pytest.mark.skip(reason="RBAC needs to land") + def test_can_edit_true(self, mocker, job_template): + pass + + @pytest.mark.skip(reason="RBAC needs to land") + def test_can_edit_false(self, mocker, job_template): + pass + +@mock.patch('awx.api.serializers.UnifiedJobTemplateSerializer.get_related', lambda x,y: {}) +@mock.patch('awx.api.serializers.JobOptionsSerializer.get_related', lambda x,y: {}) +class TestJobSerializerGetRelated(GetRelatedMixin): + @pytest.mark.parametrize("related_resource_name", [ + 'job_events', + 'job_plays', + 'job_tasks', + 'relaunch', + 'labels', + ]) + def test_get_related(self, mocker, job, related_resource_name): + self._test_get_related(JobSerializer, job, 'jobs', related_resource_name) + + def test_job_template_present(self, job): + job.job_template.active = True + serializer = JobSerializer() + related = serializer.get_related(job) + assert 'job_template' in related + + def test_job_template_absent(self, job): + job.job_template.active = False + serializer = JobSerializer() + related = serializer.get_related(job) + assert 'job_template' not in related + +@mock.patch('awx.api.serializers.BaseSerializer.get_summary_fields', lambda x,y: {}) +class TestJobOptionsSerializerGetSummaryFields(GetSummaryFieldsMixin): + def test__summary_field_labels_10_max(self, mocker, job_template, labels): + job_template.labels.all = mocker.MagicMock(**{'order_by.return_value': labels}) + job_template.labels.all.return_value = job_template.labels.all + + serializer = JobOptionsSerializer() + summary_labels = serializer._summary_field_labels(job_template) + + job_template.labels.all.order_by.assert_called_with('-name') + assert len(summary_labels) == 10 + assert summary_labels == [{'id': x.id, 'name': x.name} for x in labels[:10]] + + def test_labels_exists(self, mocker, job_template): + self._test_get_summary_fields(JobOptionsSerializer, job_template, 'labels') + diff --git a/awx/main/tests/unit/api/test_views.py b/awx/main/tests/unit/api/test_views.py new file mode 100644 index 0000000000..6a7668c472 --- /dev/null +++ b/awx/main/tests/unit/api/test_views.py @@ -0,0 +1,52 @@ +# Python +import pytest + +# AWX +from awx.api.views import ApiV1RootView + +@pytest.fixture +def mock_response_new(mocker): + m = mocker.patch('awx.api.views.Response.__new__') + m.return_value = m + return m + +class TestApiV1RootView: + def test_get_endpoints(self, mocker, mock_response_new): + endpoints = [ + 'authtoken', + 'ping', + 'config', + 'settings', + 'me', + 'dashboard', + 'organizations', + 'users', + 'projects', + 'teams', + 'credentials', + 'inventory', + 'inventory_scripts', + 'inventory_sources', + 'groups', + 'hosts', + 'job_templates', + 'jobs', + 'ad_hoc_commands', + 'system_job_templates', + 'system_jobs', + 'schedules', + 'notifiers', + 'notifications', + 'labels', + 'unified_job_templates', + 'unified_jobs', + 'activity_stream', + ] + view = ApiV1RootView() + ret = view.get(mocker.MagicMock()) + + assert ret == mock_response_new + data_arg = mock_response_new.mock_calls[0][1][1] + for endpoint in endpoints: + assert endpoint in data_arg +