From afae96b33b460b0d482fd5d51952b634fc345283 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Mon, 11 Sep 2023 11:23:10 +0545 Subject: [PATCH] Add basic test for Export Query/Task --- .gitignore | 2 + apps/common/types.py | 5 +- apps/export/exporter/xlsform.py | 4 +- apps/export/factories.py | 12 ++ apps/export/models.py | 1 + apps/export/tests/test_mutations.py | 161 ++++++++++++++++++++++++ apps/export/tests/test_queries.py | 189 ++++++++++++++++++++++++++++ apps/export/tests/test_tasks.py | 50 ++++++++ main/tests/base.py | 3 + 9 files changed, 422 insertions(+), 5 deletions(-) create mode 100644 apps/export/tests/test_mutations.py create mode 100644 apps/export/tests/test_queries.py create mode 100644 apps/export/tests/test_tasks.py diff --git a/.gitignore b/.gitignore index a395773..cbfcdbf 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ sitecustomize.py .coverage htmlcov/ .pytest_cache/ + +rest-media-temp/ diff --git a/apps/common/types.py b/apps/common/types.py index 0f40e03..eea7b16 100644 --- a/apps/common/types.py +++ b/apps/common/types.py @@ -4,7 +4,7 @@ from django.db import models from django.db.models.fields.files import FieldFile as DjFieldFile from django.http import HttpRequest -from django.core.files.storage import FileSystemStorage, storages +from django.core.files.storage import FileSystemStorage, default_storage from django.core.cache import cache from django.conf import settings @@ -41,8 +41,7 @@ def modified_by(self, info: Info) -> UserType: def get_cached_file_uri(file: DjFieldFile, request: HttpRequest) -> str | None: if file.name is None: return - - if isinstance(storages.backends['default'], FileSystemStorage): + if isinstance(default_storage, FileSystemStorage): return request.build_absolute_uri(file.url) # Other is only S3 for now cache_key = CacheKey.URL_CACHED_FILE_FIELD_KEY_FORMAT.format(CacheKey.generate_hash(file.name)) diff --git a/apps/export/exporter/xlsform.py b/apps/export/exporter/xlsform.py index 7578034..001d355 100644 --- a/apps/export/exporter/xlsform.py +++ b/apps/export/exporter/xlsform.py @@ -23,14 +23,14 @@ def __init__(self, leaf_group): class XlsQuestionType: @staticmethod def get_select_one(question): - name = f'select_one {question.choice.name}' + name = f'select_one {question.choice_collection.name}' if question.is_or_other: return f'{name} or_other' return name @staticmethod def get_select_multiple(question): - name = f'select_multiple {question.choice.name}' + name = f'select_multiple {question.choice_collection.name}' if question.is_or_other: return f'{name} or_other' return name diff --git a/apps/export/factories.py b/apps/export/factories.py index e69de29..1fe3f34 100644 --- a/apps/export/factories.py +++ b/apps/export/factories.py @@ -0,0 +1,12 @@ +from factory.django import DjangoModelFactory + +from .models import QuestionnaireExport + + +class QuestionnaireExportFactory(DjangoModelFactory): + type = QuestionnaireExport.Type.XLSFORM + status = QuestionnaireExport.Status.PENDING + file = 'random-file-path' + + class Meta: + model = QuestionnaireExport diff --git a/apps/export/models.py b/apps/export/models.py index e033cde..52c7136 100644 --- a/apps/export/models.py +++ b/apps/export/models.py @@ -44,6 +44,7 @@ class Status(models.IntegerChoices): questionnaire_id: int exported_by_id: int + get_type_display: typing.Callable[..., str] get_status_display: typing.Callable[..., str] def __str__(self): diff --git a/apps/export/tests/test_mutations.py b/apps/export/tests/test_mutations.py new file mode 100644 index 0000000..72d524d --- /dev/null +++ b/apps/export/tests/test_mutations.py @@ -0,0 +1,161 @@ +from unittest.mock import patch + +from main.tests import TestCase + +from apps.user.factories import UserFactory +from apps.project.factories import ProjectFactory +from apps.project.models import ProjectMembership +from apps.export.models import QuestionnaireExport +from apps.questionnaire.factories import QuestionnaireFactory + + +class TestExportMutation(TestCase): + class Mutation: + CREATE_EXPORT = ''' + mutation MyMutation( + $projectId: ID!, + $data: QuestionnaireExportCreateInput! + ) { + private { + id + projectScope(pk: $projectId) { + id + createQuestionnaireExport(data: $data) { + ok + errors + result { + id + type + typeDisplay + statusDisplay + status + startedAt + exportedAt + endedAt + questionnaireId + exportedBy { + id + } + file { + name + url + } + } + } + } + } + } + ''' + + DELETE_EXPORT = ''' + mutation MyMutation( + $projectId: ID!, + $questionnaireId: ID! + ) { + private { + id + projectScope(pk: $projectId) { + id + deleteQuestionnaireExport(id: $questionnaireId) { + ok + errors + result { + id + type + typeDisplay + statusDisplay + status + startedAt + exportedAt + endedAt + questionnaireId + exportedBy { + id + displayName + } + file { + name + url + } + } + } + } + } + } + ''' + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user, cls.ro_user, cls.other_user = UserFactory.create_batch(3) + + user_resource_params = {'created_by': cls.user, 'modified_by': cls.user} + cls.project = ProjectFactory.create(**user_resource_params) + cls.project.add_member(cls.user) + cls.project.add_member(cls.ro_user, role=ProjectMembership.Role.VIEWER) + + cls.q1, _ = QuestionnaireFactory.create_batch(2, project=cls.project, **user_resource_params) + + @patch('apps.export.serializers.export_task.delay') + def test_export(self, export_task_mock): + class DummyCeleryTaskResponse(): + id = 'random-async-task-id' + + variables = { + 'projectId': self.gID(self.project.id), + 'data': { + 'type': self.genum(QuestionnaireExport.Type.XLSFORM), + 'questionnaire': self.gID(self.q1.pk), + }, + } + + export_task_mock.return_value = DummyCeleryTaskResponse() + # Without authentication ----- + with self.captureOnCommitCallbacks(execute=True): + self.query_check(self.Mutation.CREATE_EXPORT, variables=variables, assert_errors=True) + export_task_mock.assert_not_called() + + # With authentication (Without access to project) ----- + self.force_login(self.other_user) + with self.captureOnCommitCallbacks(execute=True): + content = self.query_check(self.Mutation.CREATE_EXPORT, variables=variables) + export_task_mock.assert_not_called() + # assert content['data']['private']['projectScope']['questionnaireExport'] is None + assert content['data']['private']['projectScope'] is None + + # With authentication (Without access to export) ----- + self.force_login(self.ro_user) + with self.captureOnCommitCallbacks(execute=True): + content = self.query_check(self.Mutation.CREATE_EXPORT, variables=variables) + export_task_mock.assert_not_called() + # assert content['data']['private']['projectScope']['questionnaireExport'] is None + assert content['data']['private']['projectScope']['createQuestionnaireExport']['ok'] is False + assert content['data']['private']['projectScope']['createQuestionnaireExport']['errors'] is not None + + # With Access + self.force_login(self.user) + with self.captureOnCommitCallbacks(execute=True): + content = self.query_check( + self.Mutation.CREATE_EXPORT, + variables=variables + )['data']['private']['projectScope']['createQuestionnaireExport'] + export_task_mock.assert_called_once() + assert content['ok'] is True + assert content['errors'] is None + export = QuestionnaireExport.objects.get(pk=content['result']['id']) + assert export.get_task_id() == DummyCeleryTaskResponse.id + assert content['result'] == { + 'id': self.gID(export.pk), + 'type': self.genum(QuestionnaireExport.Type.XLSFORM), + 'typeDisplay': export.get_type_display(), + 'status': self.genum(QuestionnaireExport.Status.PENDING), + 'statusDisplay': export.get_status_display(), + 'exportedAt': self.gdatetime(export.exported_at), + 'startedAt': self.gdatetime(export.started_at), + 'endedAt': self.gdatetime(export.ended_at), + 'exportedBy': { + 'id': self.gID(export.exported_by_id), + }, + 'file': None, + 'questionnaireId': self.gID(self.q1.pk), + } diff --git a/apps/export/tests/test_queries.py b/apps/export/tests/test_queries.py new file mode 100644 index 0000000..9376beb --- /dev/null +++ b/apps/export/tests/test_queries.py @@ -0,0 +1,189 @@ +from main.tests import TestCase + +from django.test import override_settings + +from apps.user.factories import UserFactory +from apps.project.factories import ProjectFactory +from apps.export.factories import QuestionnaireExportFactory +from apps.questionnaire.factories import QuestionnaireFactory + + +class TestExportQuery(TestCase): + class Query: + EXPORT = ''' + query MyQuery($projectId: ID!, $questionnaireExportId: ID!) { + private { + projectScope(pk: $projectId) { + id + questionnaireExport(pk: $questionnaireExportId) { + id + type + typeDisplay + status + statusDisplay + exportedAt + startedAt + endedAt + exportedBy { + id + } + file { + url + name + } + } + } + } + } + ''' + + EXPORTS = ''' + query MyQuery($projectId: ID!) { + private { + projectScope(pk: $projectId) { + id + questionnaireExports(order: {id: ASC}) { + count + items { + id + type + typeDisplay + status + statusDisplay + exportedAt + startedAt + endedAt + exportedBy { + id + } + file { + url + name + } + } + } + } + } + } + ''' + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user1, cls.user2, cls.other_user = UserFactory.create_batch(3) + + user_resource_params = {'created_by': cls.user1, 'modified_by': cls.user1} + cls.project = ProjectFactory.create(**user_resource_params) + cls.project.add_member(cls.user1) + cls.project.add_member(cls.user2) + + q1, _ = QuestionnaireFactory.create_batch(2, project=cls.project, **user_resource_params) + + cls.exports = QuestionnaireExportFactory.create_batch(3, exported_by=cls.user1, questionnaire=q1) + + def test_export(self): + project = self.project + export = self.exports[0] + variables = { + 'projectId': self.gID(project.id), + 'questionnaireExportId': self.gID(export.id), + } + + # Without authentication ----- + self.query_check(self.Query.EXPORTS, variables=variables, assert_errors=True) + + # With authentication (Without access to project) ----- + self.force_login(self.other_user) + content = self.query_check(self.Query.EXPORT, variables=variables) + # assert content['data']['private']['projectScope']['questionnaireExport'] is None + assert content['data']['private']['projectScope'] is None + + # With authentication (Without access to export) ----- + self.force_login(self.user2) + content = self.query_check(self.Query.EXPORT, variables=variables) + # assert content['data']['private']['projectScope']['questionnaireExport'] is None + assert content['data']['private']['projectScope']['questionnaireExport'] is None + + # With Access + self.force_login(self.user1) + content = self.query_check(self.Query.EXPORT, variables=variables) + assert content['data']['private']['projectScope']['questionnaireExport'] == { + 'id': self.gID(export.id), + 'type': self.genum(export.type), + 'typeDisplay': export.get_type_display(), + 'status': self.genum(export.status), + 'statusDisplay': export.get_status_display(), + 'exportedAt': self.gdatetime(export.exported_at), + 'startedAt': self.gdatetime(export.started_at), + 'endedAt': self.gdatetime(export.ended_at), + 'exportedBy': { + 'id': self.gID(export.exported_by_id), + }, + 'file': { + 'url': self.get_media_url(export.file.name), + 'name': export.file.name, + }, + } + + def test_exports(self): + project = self.project + exports = self.exports + variables = { + 'projectId': self.gID(project.id), + } + + # Without authentication ----- + self.query_check(self.Query.EXPORTS, variables=variables, assert_errors=True) + + # With authentication (Without access) ----- + self.force_login(self.other_user) + content = self.query_check(self.Query.EXPORTS, variables=variables) + # assert content['data']['private']['projectScope']['questionnaireExport'] is None + assert content['data']['private']['projectScope'] is None + + # With Access + for backend_storage in ( + 'django.core.files.storage.FileSystemStorage', + 'main.storages.S3MediaStorage', + ): + with override_settings( + STORAGES={ + 'default': { + 'BACKEND': backend_storage, + }, + }, + ): + for user_, exports_ in [ + (self.user1, exports), + (self.user2, []), + ]: + self.force_login(user_) + content = self.query_check(self.Query.EXPORTS, variables=variables) + assert content['data']['private']['projectScope']['questionnaireExports'] == { + 'count': len(exports_), + 'items': [ + { + 'id': self.gID(export.id), + 'type': self.genum(export.type), + 'typeDisplay': export.get_type_display(), + 'status': self.genum(export.status), + 'statusDisplay': export.get_status_display(), + 'exportedAt': self.gdatetime(export.exported_at), + 'startedAt': self.gdatetime(export.started_at), + 'endedAt': self.gdatetime(export.ended_at), + 'exportedBy': { + 'id': self.gID(export.exported_by_id), + }, + 'file': { + 'name': export.file.name, + 'url': ( + f'/media/{export.file.name}' + if backend_storage == 'main.storages.S3MediaStorage' + else + self.get_media_url(export.file.name) + ), + } + } + for export in exports_ + ] + } diff --git a/apps/export/tests/test_tasks.py b/apps/export/tests/test_tasks.py new file mode 100644 index 0000000..721a5ae --- /dev/null +++ b/apps/export/tests/test_tasks.py @@ -0,0 +1,50 @@ +import random + +from main.tests import TestCase + +from apps.user.factories import UserFactory +from apps.project.factories import ProjectFactory +from apps.questionnaire.models import Question +from apps.export.factories import QuestionnaireExportFactory +from apps.export.tasks import export_task +from apps.export.models import QuestionnaireExport +from apps.questionnaire.factories import ( + QuestionnaireFactory, + QuestionFactory, + QuestionLeafGroupFactory, + ChoiceCollectionFactory, + ChoiceFactory, +) + + +class TestExportTaskQuery(TestCase): + def test_questionnaire_export(self): + user = UserFactory.create() + user_resource_params = {'created_by': user, 'modified_by': user} + project = ProjectFactory.create(**user_resource_params) + project.add_member(user) + # TODO: Add more cases + q1, _ = QuestionnaireFactory.create_batch(2, project=project, **user_resource_params) + # For q1 only + choice_collections = ChoiceCollectionFactory.create_batch( + 2, + **user_resource_params, + questionnaire=q1, + label='[Choices] Gender', + ) + ChoiceFactory.create_batch(3, collection=choice_collections[0]) + ChoiceFactory.create_batch(5, collection=choice_collections[1]) + groups = QuestionLeafGroupFactory.static_generator(20, **user_resource_params, questionnaire=q1) + for group in groups: + for type_, _ in Question.Type.choices: + question_params = {**user_resource_params} + if type_ in [Question.Type.SELECT_MULTIPLE, Question.Type.SELECT_ONE]: + question_params['choice_collection'] = random.choice(choice_collections) + QuestionFactory.create_batch(2, **question_params, leaf_group=group, type=type_) + QuestionFactory.create_batch(3, **question_params, leaf_group=group, type=type_) + QuestionFactory.create_batch(5, **question_params, leaf_group=group, type=type_) + export = QuestionnaireExportFactory.create(exported_by=user, questionnaire=q1) + + export_task(export.id) + export.refresh_from_db() + assert export.status == QuestionnaireExport.Status.SUCCESS diff --git a/main/tests/base.py b/main/tests/base.py index 99f3e6b..8b8000f 100644 --- a/main/tests/base.py +++ b/main/tests/base.py @@ -101,6 +101,9 @@ def gID(self, pk): if pk: return str(pk) + def get_media_url(self, path): + return f'http://testserver/media/{path}' + def _dict_with_keys( self, data: dict,