diff --git a/participation/tests.py b/participation/tests.py index 70aaa78..00aecc0 100644 --- a/participation/tests.py +++ b/participation/tests.py @@ -6,7 +6,7 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.sites.models import Site from django.core.files.uploadedfile import SimpleUploadedFile from django.core.management import call_command -from django.test import TestCase +from django.test import LiveServerTestCase, override_settings, TestCase from django.urls import reverse from registration.models import CoachRegistration, Payment, StudentRegistration @@ -875,6 +875,208 @@ class TestPayment(TestCase): self.assertFalse(payment.valid) +@override_settings(HELLOASSO_TEST_ENDPOINT=True, ROOT_URLCONF="tfjm.helloasso.test_urls") +class TestHelloAssoPayment(LiveServerTestCase): + """ + Tests that are relative to a HelloAsso + """ + + def setUp(self): + self.superuser = User.objects.create_superuser( + username="admin", + email="admin@example.com", + password="admin", + ) + self.tournament = Tournament.objects.create( + name="France", + place="Here", + price=21, + ) + self.team = Team.objects.create( + name="Super team", + trigram="AAA", + access_code="azerty", + ) + self.user = User.objects.create( + first_name="Toto", + last_name="Toto", + email="toto@example.com", + password="toto", + ) + StudentRegistration.objects.create( + user=self.user, + team=self.team, + student_class=12, + address="1 Rue de Rivoli", + zip_code=75001, + city="Paris", + school="Earth", + give_contact_to_animath=True, + email_confirmed=True, + ) + self.coach = User.objects.create( + first_name="Coach", + last_name="Coach", + email="coach@example.com", + password="coach", + ) + CoachRegistration.objects.create( + user=self.coach, + team=self.team, + address="1 Rue de Rivoli", + zip_code=75001, + city="Paris", + ) + + self.team.participation.tournament = self.tournament + self.team.participation.valid = True + self.team.participation.save() + self.client.force_login(self.user) + + Site.objects.update(domain=self.live_server_url.replace("http://", "")) + + def test_create_checkout_intent(self): + with self.settings(HELLOASSO_TEST_ENDPOINT_URL=self.live_server_url): + payment = Payment.objects.get(registrations=self.user.registration, final=False) + checkout_intent = payment.create_checkout_intent() + + self.assertIsNotNone(checkout_intent) + self.assertEqual(checkout_intent['metadata'], { + 'payment_id': payment.pk, + 'users': [ + { + 'user_id': self.user.pk, + 'first_name': self.user.first_name, + 'last_name': self.user.last_name, + 'email': self.user.email, + } + ], + 'final': False, + 'tournament_id': self.tournament.pk, + }) + self.assertNotIn('order', checkout_intent) + + checkout_intent_fetched = payment.get_checkout_intent() + self.assertEqual(checkout_intent, checkout_intent_fetched) + + # Don't create a new checkout intent if one already exists + checkout_intent_new = payment.create_checkout_intent() + self.assertEqual(checkout_intent, checkout_intent_new) + + payment.refresh_from_db() + self.assertEqual(payment.checkout_intent_id, checkout_intent['id']) + self.assertFalse(payment.valid) + + def test_helloasso_payment_success(self): + """ + Simulates the redirection to Hello Asso and the return for a successful payment. + """ + with self.settings(HELLOASSO_TEST_ENDPOINT_URL=self.live_server_url): + payment = Payment.objects.get(registrations=self.user.registration, final=False) + self.assertIsNone(payment.checkout_intent_id) + self.assertFalse(payment.valid) + + response = self.client.get(reverse('registration:update_payment', args=(payment.pk,))) + self.assertEqual(response.status_code, 200) + + response = self.client.get(reverse('registration:payment_hello_asso', args=(payment.pk,)), + follow=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.redirect_chain[-1], + (reverse('participation:team_detail', args=(self.team.pk,)), 302)) + self.assertIn("type=return", response.redirect_chain[1][0]) + self.assertIn("code=succeeded", response.redirect_chain[1][0]) + + payment.refresh_from_db() + self.assertIsNotNone(payment.checkout_intent_id) + self.assertTrue(payment.valid) + + checkout_intent = payment.get_checkout_intent() + self.assertIn('order', checkout_intent) + + def test_helloasso_payment_refused(self): + """ + Simulates the redirection to Hello Asso and the return for a refused payment. + """ + with self.settings(HELLOASSO_TEST_ENDPOINT_URL=self.live_server_url): + payment = Payment.objects.get(registrations=self.user.registration, final=False) + checkout_intent = payment.create_checkout_intent() + self.assertFalse(payment.valid) + + response = self.client.get(reverse('registration:update_payment', args=(payment.pk,))) + self.assertEqual(response.status_code, 200) + + response = self.client.get(checkout_intent['redirectUrl'] + "?refused", follow=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.redirect_chain[-1], + (reverse('registration:update_payment', args=(payment.pk,)), 302)) + self.assertIn("type=return", response.redirect_chain[0][0]) + self.assertIn("code=refused", response.redirect_chain[0][0]) + + payment.refresh_from_db() + self.assertFalse(payment.valid) + + checkout_intent = payment.get_checkout_intent() + self.assertNotIn('order', checkout_intent) + + def test_helloasso_payment_error(self): + """ + Simulates the redirection to Hello Asso and the return for an errored payment. + """ + with self.settings(HELLOASSO_TEST_ENDPOINT_URL=self.live_server_url): + payment = Payment.objects.get(registrations=self.user.registration, final=False) + checkout_intent = payment.create_checkout_intent() + self.assertFalse(payment.valid) + + response = self.client.get(reverse('registration:update_payment', args=(payment.pk,))) + self.assertEqual(response.status_code, 200) + + response = self.client.get(checkout_intent['redirectUrl'] + "?error", follow=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.redirect_chain[-1], + (reverse('registration:update_payment', args=(payment.pk,)), 302)) + self.assertIn("type=error", response.redirect_chain[0][0]) + self.assertIn("error=", response.redirect_chain[0][0]) + + payment.refresh_from_db() + self.assertFalse(payment.valid) + + checkout_intent = payment.get_checkout_intent() + self.assertNotIn('order', checkout_intent) + + def test_anonymous_payment(self): + """ + Test to make a successful payment from an anonymous user, authenticated by token. + """ + self.client.logout() + + with self.settings(HELLOASSO_TEST_ENDPOINT_URL=self.live_server_url): + payment = Payment.objects.get(registrations=self.user.registration, final=False) + self.assertIsNone(payment.checkout_intent_id) + self.assertFalse(payment.valid) + + response = self.client.get(reverse('registration:payment_hello_asso', args=(payment.pk,)), + follow=True) + self.assertRedirects(response, + f"{reverse('login')}?next=" + f"{reverse('registration:payment_hello_asso', args=(payment.pk,))}") + + response = self.client.get( + reverse('registration:payment_hello_asso', args=(payment.pk,)) + "?token=" + payment.token, + follow=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.redirect_chain[-1], (reverse('index'), 302)) + self.assertIn("type=return", response.redirect_chain[1][0]) + self.assertIn("code=succeeded", response.redirect_chain[1][0]) + + payment.refresh_from_db() + self.assertIsNotNone(payment.checkout_intent_id) + self.assertTrue(payment.valid) + + checkout_intent = payment.get_checkout_intent() + self.assertIn('order', checkout_intent) + + class TestAdmin(TestCase): def setUp(self) -> None: self.user = User.objects.create_superuser( diff --git a/tfjm/helloasso/__init__.py b/tfjm/helloasso/__init__.py index de41fb8..0b3139d 100644 --- a/tfjm/helloasso/__init__.py +++ b/tfjm/helloasso/__init__.py @@ -13,7 +13,7 @@ _expires_at = None def _get_hello_asso_api_base_url(): if settings.HELLOASSO_TEST_ENDPOINT: - return f"{settings.HELLOASSO_TEST_ENDPOINT_URL}/helloasso-test" + return f"{settings.HELLOASSO_TEST_ENDPOINT_URL}/helloasso-test/api" elif not settings.DEBUG: return "https://api.helloasso.com" else: diff --git a/tfjm/helloasso/test_urls.py b/tfjm/helloasso/test_urls.py new file mode 100644 index 0000000..dbcac4b --- /dev/null +++ b/tfjm/helloasso/test_urls.py @@ -0,0 +1,23 @@ +# Copyright (C) 2024 by Animath +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.urls import path +import tfjm.urls + +from . import test_views + +urlpatterns = tfjm.urls.urlpatterns + +urlpatterns += [ + path('helloasso-test/api/oauth2/token', test_views.TestHelloAssoOAuth2View.as_view(), + name='helloasso-test-oauth2-token'), + path('helloasso-test/api/v5/organizations/animath/checkout-intents/', + test_views.TestHelloAssoCheckoutIntentCreateView.as_view(), + name='helloasso-test-checkout-intent-create'), + path('helloasso-test/api/v5/organizations/animath/checkout-intents//', + test_views.TestHelloAssoCheckoutIntentDetailView.as_view(), + name='helloasso-test-checkout-intent-detail'), + path('helloasso-test/redirect-payment//', + test_views.TestHelloAssoRedirectPaymentView.as_view(), + name='helloasso-test-redirect-payment'), +] diff --git a/tfjm/helloasso/test_views.py b/tfjm/helloasso/test_views.py new file mode 100644 index 0000000..034653c --- /dev/null +++ b/tfjm/helloasso/test_views.py @@ -0,0 +1,149 @@ +# Copyright (C) 2024 by Animath +# SPDX-License-Identifier: GPL-3.0-or-later + +import json + +from django.conf import settings +from django.http import Http404, HttpResponse, JsonResponse +from django.shortcuts import redirect +from django.urls import reverse +from django.utils import timezone +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from django.views.generic.base import View + +_CHECKOUT_INTENTS = {} + + +@method_decorator(csrf_exempt, name='dispatch') +class TestHelloAssoOAuth2View(View): + def post(self, request, *args, **kwargs): + data = { + 'access_token': 'test_access_token', + 'refresh_token': 'test_refresh_token', + 'expires_in': 3600, + } + return JsonResponse(data) + + +@method_decorator(csrf_exempt, name='dispatch') +class TestHelloAssoCheckoutIntentCreateView(View): + def post(self, request, *args, **kwargs): + checkout_intent_id = len(_CHECKOUT_INTENTS) + 1 + body = json.loads(request.body.decode()) + + body['backUrl'] = body['backUrl'].replace("https", "http") + body['returnUrl'] = body['returnUrl'].replace("https", "http") + body['errorUrl'] = body['errorUrl'].replace("https", "http") + + output_data = { + 'id': checkout_intent_id, + 'redirectUrl': f"{settings.HELLOASSO_TEST_ENDPOINT_URL}" + f"{reverse('helloasso-test-redirect-payment', args=(checkout_intent_id,))}", + 'metadata': body['metadata'], + } + + checkout_intent = {'input': body, 'output': output_data} + _CHECKOUT_INTENTS[checkout_intent_id] = checkout_intent + + return JsonResponse(output_data) + + +class TestHelloAssoCheckoutIntentDetailView(View): + def get(self, request, *args, **kwargs): + checkout_intent_id = kwargs['checkout_intent_id'] + if checkout_intent_id not in _CHECKOUT_INTENTS: + raise Http404 + return JsonResponse(_CHECKOUT_INTENTS[checkout_intent_id]['output']) + + +class TestHelloAssoRedirectPaymentView(View): + def get(self, request, *args, **kwargs): + checkout_intent_id = kwargs['checkout_intent_id'] + if checkout_intent_id not in _CHECKOUT_INTENTS: + raise Http404 + + checkout_intent = _CHECKOUT_INTENTS[checkout_intent_id] + ci_input = checkout_intent['input'] + ci_output = checkout_intent['output'] + + if 'error' in request.GET: + return redirect(ci_input['errorUrl'] + f"&checkoutIntentId={checkout_intent_id}&error=An error occurred.") + elif 'refused' in request.GET: + return redirect(ci_input['returnUrl'] + f"&checkoutIntentId={checkout_intent_id}&code=refused") + + dt = timezone.now().isoformat() + + ci_output['order'] = { + 'payer': { + 'email': 'payer@example.com', + 'country': 'FRA', + 'dateOfBirth': '2000-01-01T00:00:00+01:00', + 'firstName': "Payer", + 'lastName': "Payer", + }, + 'items': [ + { + 'payments': [ + { + 'id': checkout_intent_id, + 'shareAmount': ci_input['totalAmount'], + } + ], + 'name': ci_input['itemName'], + 'priceCategory': 'Fixed', + 'qrCode': '', + 'id': checkout_intent_id, + 'amount': ci_input['totalAmount'], + 'type': 'Payment', + 'state': 'Processed' + } + ], + 'payments': [ + { + 'items': [ + { + 'id': checkout_intent_id, + 'shareAmount': ci_input['totalAmount'], + 'shareItemAmount': ci_input['totalAmount'], + } + ], + 'cashOutState': 'MoneyIn', + 'paymentReceiptUrl': "https://example.com/", + 'id': checkout_intent_id, + 'amount': ci_input['totalAmount'], + 'date': dt, + 'paymentMeans': 'Card', + 'installmentNumber': 1, + 'state': 'Authorized', + 'meta': { + 'createdAt': dt, + 'updatedAt': dt, + }, + 'refundOperations': [] + } + ], + 'amount': { + 'total': ci_input['totalAmount'], + 'vat': 0, + 'discount': 0 + }, + 'id': 13339, + 'date': dt, + 'formSlug': 'default', + 'formType': 'Checkout', + 'organizationName': 'Animath', + 'organizationSlug': 'animath', + 'checkoutIntentId': checkout_intent_id, + 'meta': { + 'createdAt': dt, + 'updatedAt': dt, + }, + 'isAnonymous': False, + 'isAmountHidden': False + } + + return redirect(ci_input['returnUrl'] + f"&checkoutIntentId={checkout_intent_id}&code=succeeded") + + def head(self, request, *args, **kwargs): + return HttpResponse() diff --git a/tfjm/settings.py b/tfjm/settings.py index f9a8767..b297970 100644 --- a/tfjm/settings.py +++ b/tfjm/settings.py @@ -244,6 +244,7 @@ PHONENUMBER_DEFAULT_REGION = 'FR' # Hello Asso API creds HELLOASSO_CLIENT_ID = os.getenv('HELLOASSO_CLIENT_ID', 'CHANGE_ME_IN_ENV_SETTINGS') HELLOASSO_CLIENT_SECRET = os.getenv('HELLOASSO_CLIENT_SECRET', 'CHANGE_ME_IN_ENV_SETTINGS') +HELLOASSO_TEST_ENDPOINT = False # Enable custom test endpoint, for unit tests # Custom parameters PROBLEMS = [