From 776ff28141dc96e2ab13e8b35bb71f42637dae3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Fri, 19 Jan 2018 17:48:00 +0100 Subject: [PATCH 1/4] cof -- Add helpers to test cof views. --- gestioncof/tests/__init__.py | 0 gestioncof/{tests.py => tests/test_legacy.py} | 0 gestioncof/tests/testcases.py | 24 ++ gestioncof/tests/utils.py | 51 +++ shared/tests/testcases.py | 294 ++++++++++++++++++ 5 files changed, 369 insertions(+) create mode 100644 gestioncof/tests/__init__.py rename gestioncof/{tests.py => tests/test_legacy.py} (100%) create mode 100644 gestioncof/tests/testcases.py create mode 100644 gestioncof/tests/utils.py create mode 100644 shared/tests/testcases.py diff --git a/gestioncof/tests/__init__.py b/gestioncof/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gestioncof/tests.py b/gestioncof/tests/test_legacy.py similarity index 100% rename from gestioncof/tests.py rename to gestioncof/tests/test_legacy.py diff --git a/gestioncof/tests/testcases.py b/gestioncof/tests/testcases.py new file mode 100644 index 00000000..b53f2866 --- /dev/null +++ b/gestioncof/tests/testcases.py @@ -0,0 +1,24 @@ +from shared.tests.testcases import ViewTestCaseMixin as BaseViewTestCaseMixin + +from .utils import create_user, create_member, create_staff + + +class ViewTestCaseMixin(BaseViewTestCaseMixin): + """ + TestCase extension to ease testing of cof views. + + Most information can be found in the base parent class doc. + This class performs some changes to users management, detailed below. + + During setup, three users are created: + - 'user': a basic user without any permission, + - 'member': (profile.is_cof is True), + - 'staff': (profile.is_cof is True) && (profile.is_buro is True). + """ + + def get_users_base(self): + return { + 'user': create_user('user'), + 'member': create_member('member'), + 'staff': create_staff('staff'), + } diff --git a/gestioncof/tests/utils.py b/gestioncof/tests/utils.py new file mode 100644 index 00000000..8d55680a --- /dev/null +++ b/gestioncof/tests/utils.py @@ -0,0 +1,51 @@ +from django.contrib.auth import get_user_model + +User = get_user_model() + + +def _create_user(username, is_cof=False, is_staff=False, attrs=None): + if attrs is None: + attrs = {} + + password = attrs.pop('password', username) + + user_keys = ['first_name', 'last_name', 'email', 'is_staff'] + user_attrs = {k: v for k, v in attrs.items() if k in user_keys} + + profile_keys = [ + 'is_cof', 'login_clipper', 'phone', 'occupation', 'departement', + 'type_cotiz', 'mailing_cof', 'mailing_bda', 'mailing_bda_revente', + 'comments', 'is_buro', 'petit_cours_accept', + 'petit_cours_remarques', + ] + profile_attrs = {k: v for k, v in attrs.items() if k in profile_keys} + + if is_cof: + profile_attrs['is_cof'] = True + + if is_staff: + # At the moment, admin is accessible by COF staff. + user_attrs['is_staff'] = True + profile_attrs['is_buro'] = True + + user = User(username=username, **user_attrs) + user.set_password(password) + user.save() + + for k, v in profile_attrs.items(): + setattr(user.profile, k, v) + user.profile.save() + + return user + + +def create_user(username, attrs=None): + return _create_user(username, attrs=attrs) + + +def create_member(username, attrs=None): + return _create_user(username, is_cof=True, attrs=attrs) + + +def create_staff(username, attrs=None): + return _create_user(username, is_cof=True, is_staff=True, attrs=attrs) diff --git a/shared/tests/testcases.py b/shared/tests/testcases.py new file mode 100644 index 00000000..15792383 --- /dev/null +++ b/shared/tests/testcases.py @@ -0,0 +1,294 @@ +from unittest import mock +from urllib.parse import parse_qs, urlparse + +from django.contrib.auth import get_user_model +from django.core.urlresolvers import reverse +from django.http import QueryDict +from django.test import Client +from django.utils import timezone +from django.utils.functional import cached_property + +User = get_user_model() + + +class TestCaseMixin: + + def assertForbidden(self, response): + """ + Test that the response (retrieved with a Client) is a denial of access. + + The response should verify one of the following: + - its HTTP response code is 403, + - it redirects to the login page with a GET parameter named 'next' + whose value is the url of the requested page. + + """ + request = response.wsgi_request + + try: + try: + # Is this an HTTP Forbidden response ? + self.assertEqual(response.status_code, 403) + except AssertionError: + # A redirection to the login view is fine too. + + # Let's build the login url with the 'next' param on current + # page. + full_path = request.get_full_path() + + querystring = QueryDict(mutable=True) + querystring['next'] = full_path + + login_url = '{}?{}'.format( + reverse('cof-login'), querystring.urlencode(safe='/')) + + # We don't focus on what the login view does. + # So don't fetch the redirect. + self.assertRedirects( + response, login_url, + fetch_redirect_response=False, + ) + except AssertionError: + raise AssertionError( + "%(http_method)s request at %(path)s should be forbidden for " + "%(username)s user.\n" + "Response isn't 403, nor a redirect to login view. Instead, " + "response code is %(code)d." % { + 'http_method': request.method, + 'path': request.get_full_path(), + 'username': ( + "'{}'".format(request.user) + if request.user.is_authenticated() + else 'anonymous' + ), + 'code': response.status_code, + } + ) + + def assertUrlsEqual(self, actual, expected): + """ + Test that the url 'actual' is as 'expected'. + + Arguments: + actual (str): Url to verify. + expected: Two forms are accepted. + * (str): Expected url. Strings equality is checked. + * (dict): Its keys must be attributes of 'urlparse(actual)'. + Equality is checked for each present key, except for + 'query' which must be a dict of the expected query string + parameters. + + """ + if type(expected) == dict: + parsed = urlparse(actual) + for part, expected_part in expected.items(): + if part == 'query': + self.assertDictEqual( + parse_qs(parsed.query), + expected.get('query', {}), + ) + else: + self.assertEqual(getattr(parsed, part), expected_part) + else: + self.assertEqual(actual, expected) + + +class ViewTestCaseMixin(TestCaseMixin): + """ + TestCase extension to ease tests of kfet views. + + + Urls concerns + ------------- + + # Basic usage + + Attributes: + url_name (str): Name of view under test, as given to 'reverse' + function. + url_args (list, optional): Will be given to 'reverse' call. + url_kwargs (dict, optional): Same. + url_expcted (str): What 'reverse' should return given previous + attributes. + + View url can then be accessed at the 'url' attribute. + + # Advanced usage + + If multiple combinations of url name, args, kwargs can be used for a view, + it is possible to define 'urls_conf' attribute. It must be a list whose + each item is a dict defining arguments for 'reverse' call ('name', 'args', + 'kwargs' keys) and its expected result ('expected' key). + + The reversed urls can be accessed at the 't_urls' attribute. + + + Users concerns + -------------- + + During setup, the following users are created: + - 'user': a basic user without any permission, + - 'root': a superuser, account trigramme: 200. + Their password is their username. + + One can create additionnal users with 'get_users_extra' method, or prevent + these users to be created with 'get_users_base' method. See these two + methods for further informations. + + By using 'register_user' method, these users can then be accessed at + 'users' attribute by their label. + + A user label can be given to 'auth_user' attribute. The related user is + then authenticated on self.client during test setup. Its value defaults to + 'None', meaning no user is authenticated. + + + Automated tests + --------------- + + # Url reverse + + Based on url-related attributes/properties, the test 'test_urls' checks + that expected url is returned by 'reverse' (once with basic url usage and + each for advanced usage). + + # Forbidden responses + + The 'test_forbidden' test verifies that each user, from labels of + 'auth_forbidden' attribute, can't access the url(s), i.e. response should + be a 403, or a redirect to login view. + + Tested HTTP requests are given by 'http_methods' attribute. Additional data + can be given by defining an attribute '_data'. + + """ + url_name = None + url_expected = None + + http_methods = ['GET'] + + auth_user = None + auth_forbidden = [] + + def setUp(self): + """ + Warning: Do not forget to call super().setUp() in subclasses. + """ + # Signals handlers on login/logout send messages. + # Due to the way the Django' test Client performs login, this raise an + # error. As workaround, we mock the Django' messages module. + patcher_messages = mock.patch('gestioncof.signals.messages') + patcher_messages.start() + self.addCleanup(patcher_messages.stop) + + # A test can mock 'django.utils.timezone.now' and give this as return + # value. E.g. it is useful if the test checks values of 'auto_now' or + # 'auto_now_add' fields. + self.now = timezone.now() + + # Register of User instances. + self.users = {} + + for label, user in dict(self.users_base, **self.users_extra).items(): + self.register_user(label, user) + + if self.auth_user: + # The wrapper is a sanity check. + self.assertTrue( + self.client.login( + username=self.auth_user, + password=self.auth_user, + ) + ) + + def tearDown(self): + del self.users_base + del self.users_extra + + def get_users_base(self): + """ + Dict of . + + Note: Don't access yourself this property. Use 'users_base' attribute + which cache the returned value from here. + It allows to give functions calls, which creates users instances, as + values here. + + """ + return { + 'user': User.objects.create_user('user', '', 'user'), + 'root': User.objects.create_superuser('root', '', 'root'), + } + + @cached_property + def users_base(self): + return self.get_users_base() + + def get_users_extra(self): + """ + Dict of . + + Note: Don't access yourself this property. Use 'users_base' attribute + which cache the returned value from here. + It allows to give functions calls, which create users instances, as + values here. + + """ + return {} + + @cached_property + def users_extra(self): + return self.get_users_extra() + + def register_user(self, label, user): + self.users[label] = user + + def get_user(self, label): + if self.auth_user is not None: + return self.auth_user + return self.auth_user_mapping.get(label) + + @property + def urls_conf(self): + return [{ + 'name': self.url_name, + 'args': getattr(self, 'url_args', []), + 'kwargs': getattr(self, 'url_kwargs', {}), + 'expected': self.url_expected, + }] + + @property + def t_urls(self): + return [ + reverse( + url_conf['name'], + args=url_conf.get('args', []), + kwargs=url_conf.get('kwargs', {}), + ) + for url_conf in self.urls_conf] + + @property + def url(self): + return self.t_urls[0] + + def test_urls(self): + for url, conf in zip(self.t_urls, self.urls_conf): + self.assertEqual(url, conf['expected']) + + def test_forbidden(self): + for method in self.http_methods: + for user in self.auth_forbidden: + for url in self.t_urls: + self.check_forbidden(method, url, user) + + def check_forbidden(self, method, url, user=None): + method = method.lower() + client = Client() + if user is not None: + client.login(username=user, password=user) + + send_request = getattr(client, method) + data = getattr(self, '{}_data'.format(method), {}) + + r = send_request(url, data) + self.assertForbidden(r) From 57de31d59a82906b935237f533844397efa5e4e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Fri, 19 Jan 2018 17:57:43 +0100 Subject: [PATCH 2/4] cof -- Add tests for survey views --- gestioncof/tests/test_views.py | 178 +++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 gestioncof/tests/test_views.py diff --git a/gestioncof/tests/test_views.py b/gestioncof/tests/test_views.py new file mode 100644 index 00000000..b8860811 --- /dev/null +++ b/gestioncof/tests/test_views.py @@ -0,0 +1,178 @@ +from django.contrib import messages +from django.contrib.messages import get_messages +from django.contrib.messages.storage.base import Message +from django.test import TestCase + +from gestioncof.models import Survey, SurveyAnswer +from gestioncof.tests.testcases import ViewTestCaseMixin + + +class SurveyViewTests(ViewTestCaseMixin, TestCase): + url_name = 'survey.details' + + http_methods = ['GET', 'POST'] + + auth_user = 'user' + auth_forbidden = [None] + + post_expected_message = Message(messages.SUCCESS, ( + "Votre réponse a bien été enregistrée ! Vous pouvez cependant la " + "modifier jusqu'à la fin du sondage." + )) + + @property + def url_kwargs(self): + return {'survey_id': self.s.pk} + + @property + def url_expected(self): + return '/survey/{}'.format(self.s.pk) + + def setUp(self): + super().setUp() + + self.s = Survey.objects.create(title='Title') + + self.q1 = self.s.questions.create(question='Question 1 ?') + self.q2 = self.s.questions.create( + question='Question 2 ?', + multi_answers=True, + ) + + self.qa1 = self.q1.answers.create(answer='Q1 - Answer 1') + self.qa2 = self.q1.answers.create(answer='Q1 - Answer 2') + self.qa3 = self.q2.answers.create(answer='Q2 - Answer 1') + self.qa4 = self.q2.answers.create(answer='Q2 - Answer 2') + + def test_get(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def test_post_new(self): + r = self.client.post(self.url, { + 'question_{}'.format(self.q1.pk): [str(self.qa1.pk)], + 'question_{}'.format(self.q2.pk): [ + str(self.qa3.pk), str(self.qa4.pk), + ], + }) + + self.assertEqual(r.status_code, 200) + self.assertIn(self.post_expected_message, get_messages(r.wsgi_request)) + + a = self.s.surveyanswer_set.get(user=self.users['user']) + self.assertQuerysetEqual( + a.answers.all(), map(repr, [self.qa1, self.qa3, self.qa4]), + ordered=False, + ) + + def test_post_edit(self): + a = self.s.surveyanswer_set.create(user=self.users['user']) + a.answers.add(self.qa1, self.qa1, self.qa4) + + r = self.client.post(self.url, { + 'question_{}'.format(self.q1.pk): [], + 'question_{}'.format(self.q2.pk): [str(self.qa3.pk)], + }) + + self.assertEqual(r.status_code, 200) + self.assertIn(self.post_expected_message, get_messages(r.wsgi_request)) + + a.refresh_from_db() + self.assertQuerysetEqual( + a.answers.all(), map(repr, [self.qa3]), + ordered=False, + ) + + def test_post_delete(self): + a = self.s.surveyanswer_set.create(user=self.users['user']) + a.answers.add(self.qa1, self.qa4) + + r = self.client.post(self.url, {'delete': '1'}) + + self.assertEqual(r.status_code, 200) + expected_message = Message( + messages.SUCCESS, "Votre réponse a bien été supprimée") + self.assertIn(expected_message, get_messages(r.wsgi_request)) + + with self.assertRaises(SurveyAnswer.DoesNotExist): + a.refresh_from_db() + + def test_forbidden_closed(self): + self.s.survey_open = False + self.s.save() + + r = self.client.get(self.url) + + self.assertNotEqual(r.status_code, 200) + + def test_forbidden_old(self): + self.s.old = True + self.s.save() + + r = self.client.get(self.url) + + self.assertNotEqual(r.status_code, 200) + + +class SurveyStatusViewTests(ViewTestCaseMixin, TestCase): + url_name = 'survey.details.status' + + http_methods = ['GET', 'POST'] + + auth_user = 'staff' + auth_forbidden = [None, 'user', 'member'] + + @property + def url_kwargs(self): + return {'survey_id': self.s.pk} + + @property + def url_expected(self): + return '/survey/{}/status'.format(self.s.pk) + + def setUp(self): + super().setUp() + + self.s = Survey.objects.create(title='Title') + + self.q1 = self.s.questions.create(question='Question 1 ?') + self.q2 = self.s.questions.create( + question='Question 2 ?', + multi_answers=True, + ) + + self.qa1 = self.q1.answers.create(answer='Q1 - Answer 1') + self.qa2 = self.q1.answers.create(answer='Q1 - Answer 2') + self.qa3 = self.q2.answers.create(answer='Q2 - Answer 1') + self.qa4 = self.q2.answers.create(answer='Q2 - Answer 2') + + self.a1 = self.s.surveyanswer_set.create(user=self.users['user']) + self.a1.answers.add(self.qa1) + self.a2 = self.s.surveyanswer_set.create(user=self.users['member']) + + def test_get(self): + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + def _get_qa_filter_name(self, qa): + return 'question_{}_answer_{}'.format(qa.survey_question.pk, qa.pk) + + def _test_filters(self, filters, expected): + r = self.client.post(self.url, { + self._get_qa_filter_name(qa): v for qa, v in filters + }) + + self.assertEqual(r.status_code, 200) + self.assertQuerysetEqual( + r.context['user_answers'], map(repr, expected), + ordered=False, + ) + + def test_filter_none(self): + self._test_filters([(self.qa1, 'none')], [self.a1, self.a2]) + + def test_filter_yes(self): + self._test_filters([(self.qa1, 'yes')], [self.a1]) + + def test_filter_no(self): + self._test_filters([(self.qa1, 'no')], [self.a2]) From 8675948d9e849acc028e79c78f7aed92b68101ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Fri, 19 Jan 2018 18:01:36 +0100 Subject: [PATCH 3/4] cof -- Fix urls naming in survey templates --- gestioncof/templates/gestioncof/survey.html | 2 +- gestioncof/templates/survey_status.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gestioncof/templates/gestioncof/survey.html b/gestioncof/templates/gestioncof/survey.html index ccf447ef..9d4d67b3 100644 --- a/gestioncof/templates/gestioncof/survey.html +++ b/gestioncof/templates/gestioncof/survey.html @@ -8,7 +8,7 @@ {% if survey.details %}

{{ survey.details }}

{% endif %} -
+ {% csrf_token %} {{ form | bootstrap}} diff --git a/gestioncof/templates/survey_status.html b/gestioncof/templates/survey_status.html index 831a07bb..0e630c6e 100644 --- a/gestioncof/templates/survey_status.html +++ b/gestioncof/templates/survey_status.html @@ -11,7 +11,7 @@ {% endif %}

Filtres

{% include "tristate_js.html" %} - + {% csrf_token %} {{ form.as_p }} From ce734990776fe25f62be96b05ad589027bde0a77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Fri, 19 Jan 2018 18:15:57 +0100 Subject: [PATCH 4/4] Fix use of Widget.build_attrs in TriStateCheckbox Signature changed in Django 1.11. --- gestioncof/widgets.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gestioncof/widgets.py b/gestioncof/widgets.py index 758fc4ad..cbc9cd93 100644 --- a/gestioncof/widgets.py +++ b/gestioncof/widgets.py @@ -20,6 +20,7 @@ class TriStateCheckbox(Widget): def render(self, name, value, attrs=None, choices=()): if value is None: value = 'none' - final_attrs = self.build_attrs(attrs, value=value) + attrs['value'] = value + final_attrs = self.build_attrs(self.attrs, attrs) output = ["" % flatatt(final_attrs)] return mark_safe('\n'.join(output))