Changeset View
Changeset View
Standalone View
Standalone View
looper/tests/test_checkout.py
| import datetime | import datetime | ||||
| import typing | import typing | ||||
| from unittest import mock | from unittest import mock | ||||
| from django.contrib.auth.models import User | from django.contrib.auth.models import User | ||||
| from django.dispatch import receiver | from django.dispatch import receiver | ||||
| from django.test import override_settings | from django.test import override_settings | ||||
| from django.urls import reverse, reverse_lazy | from django.urls import reverse, reverse_lazy | ||||
| from django.utils import timezone | from django.utils import timezone | ||||
| from django.http import HttpResponse | from django.http import HttpResponse | ||||
| import responses | import responses | ||||
| from looper.exceptions import GatewayError | from looper.exceptions import GatewayError | ||||
| from looper.tests.base import AbstractLooperTestCase | |||||
| from .. import models, signals, gateways | from .. import models, signals, gateways | ||||
| from . import AbstractLooperTestCase | |||||
| # Prevent communication with Google's reCAPTCHA API. | # Prevent communication with Google's reCAPTCHA API. | ||||
| @override_settings(GOOGLE_RECAPTCHA_SECRET_KEY='') | @override_settings(GOOGLE_RECAPTCHA_SECRET_KEY='') | ||||
| class AbstractCheckoutTestCase(AbstractLooperTestCase): | class AbstractCheckoutTestCase(AbstractLooperTestCase): | ||||
| fixtures = ['devfund', 'testuser', 'systemuser'] | fixtures = ['devfund', 'testuser', 'systemuser'] | ||||
| valid_payload = { | valid_payload = { | ||||
| 'gateway': 'braintree', | 'gateway': 'braintree', | ||||
| 'payment_method_nonce': 'fake-valid-nonce', | 'payment_method_nonce': 'fake-valid-nonce', | ||||
| 'full_name': 'Erik von Namenstein', | 'full_name': 'Erik von Namenstein', | ||||
| 'company': 'Nöming Thingéys', | 'company': 'Nöming Thingéys', | ||||
| 'street_address': 'Scotland Pl', | 'street_address': 'Scotland Pl', | ||||
| 'locality': 'Amsterdongel', | 'locality': 'Amsterdongel', | ||||
| 'postal_code': '1025 ET', | 'postal_code': '1025 ET', | ||||
| 'region': 'Worbelmoster', | 'region': 'Worbelmoster', | ||||
| 'country': 'NL', | 'country': 'NL', | ||||
| } | } | ||||
| checkout_url = reverse_lazy('looper:checkout', | checkout_url = reverse_lazy('looper:checkout', kwargs={'plan_id': 2, 'plan_variation_id': 5}) | ||||
| kwargs={'plan_id': 2, 'plan_variation_id': 5}) | |||||
| def setUp(self): | def setUp(self): | ||||
| super().setUp() | super().setUp() | ||||
| self.user = User.objects.get(email='harry@blender.org') | self.user = User.objects.get(email='harry@blender.org') | ||||
| # The signed-cookies session engine doesn't play nice with the test client. | # The signed-cookies session engine doesn't play nice with the test client. | ||||
| @override_settings(SESSION_ENGINE='django.contrib.sessions.backends.file') | @override_settings(SESSION_ENGINE='django.contrib.sessions.backends.file') | ||||
| class BraintreeClientTokenTestCase(AbstractCheckoutTestCase): | class BraintreeClientTokenTestCase(AbstractCheckoutTestCase): | ||||
| def set_session(self, token: str, expire: str): | def set_session(self, token: str, expire: str): | ||||
| session = self.client.session | session = self.client.session | ||||
| if token: | if token: | ||||
| session['PAYMENT_GATEWAY_CLIENT_TOKEN_1_USD'] = token | session['PAYMENT_GATEWAY_CLIENT_TOKEN_1_USD'] = token | ||||
| if expire: | if expire: | ||||
| session['PAYMENT_GATEWAY_CLIENT_TOKEN_1_USD_expire'] = expire | session['PAYMENT_GATEWAY_CLIENT_TOKEN_1_USD_expire'] = expire | ||||
| session.save() | session.save() | ||||
| return session | return session | ||||
| Show All 34 Lines | class BraintreeClientTokenTestCase(AbstractCheckoutTestCase): | ||||
| @mock.patch('braintree.client_token_gateway.ClientTokenGateway.generate') | @mock.patch('braintree.client_token_gateway.ClientTokenGateway.generate') | ||||
| def test_token_in_session__expiry_just_right(self, mock_generate): | def test_token_in_session__expiry_just_right(self, mock_generate): | ||||
| mock_generate.side_effect = ['mock-client-token'] | mock_generate.side_effect = ['mock-client-token'] | ||||
| expiry = datetime.datetime.now() + datetime.timedelta(minutes=5) | expiry = datetime.datetime.now() + datetime.timedelta(minutes=5) | ||||
| self.client.force_login(self.user) | self.client.force_login(self.user) | ||||
| session = self.set_session('token-in-session', expiry.isoformat()) | self.set_session('token-in-session', expiry.isoformat()) | ||||
| r = self.client.get(self.checkout_url) | r = self.client.get(self.checkout_url) | ||||
| self.assertEqual(r.status_code, 200) | self.assertEqual(r.status_code, 200) | ||||
| # Not trustworthy in the session, the cached token should not be reused. | # Not trustworthy in the session, the cached token should not be reused. | ||||
| mock_generate.assert_not_called() | mock_generate.assert_not_called() | ||||
| class CheckoutTestCase(AbstractCheckoutTestCase): | class CheckoutTestCase(AbstractCheckoutTestCase): | ||||
| def setUp(self): | def setUp(self): | ||||
| super().setUp() | super().setUp() | ||||
| # Do a GET first to mimick the normal request flow. | # Do a GET first to mimick the normal request flow. | ||||
| self.client.force_login(self.user) | self.client.force_login(self.user) | ||||
| r = self.client.get(self.checkout_url) | r = self.client.get(self.checkout_url) | ||||
| self.assertEqual(r.status_code, 200) | self.assertEqual(r.status_code, 200) | ||||
| ▲ Show 20 Lines • Show All 41 Lines • ▼ Show 20 Lines | def test_checkout_create_valid_subscription(self): | ||||
| url = reverse('looper:checkout', kwargs={'plan_id': 2, 'plan_variation_id': 6}) | url = reverse('looper:checkout', kwargs={'plan_id': 2, 'plan_variation_id': 6}) | ||||
| r = self.client.post(url, data=self.valid_payload) | r = self.client.post(url, data=self.valid_payload) | ||||
| self.assertEqual(302, r.status_code, r.content.decode()) | self.assertEqual(302, r.status_code, r.content.decode()) | ||||
| self.assertIsNotNone(created_subs) | self.assertIsNotNone(created_subs) | ||||
| self.assertEqual('active', created_subs.status) | self.assertEqual('active', created_subs.status) | ||||
| for key in self.client.session.keys(): | for key in self.client.session.keys(): | ||||
| self.assertFalse(key.startswith('PAYMENT_GATEWAY_'), | self.assertFalse( | ||||
| f'Did not expect {key!r} in the session') | key.startswith('PAYMENT_GATEWAY_'), f'Did not expect {key!r} in the session' | ||||
| ) | |||||
| # We should now be able to get the 'done' view for this order. | # We should now be able to get the 'done' view for this order. | ||||
| order = created_subs.latest_order() | order = created_subs.latest_order() | ||||
| transaction = order.latest_transaction() | transaction = order.latest_transaction() | ||||
| success_url = reverse('looper:checkout_done', | success_url = reverse('looper:checkout_done', kwargs={'transaction_id': transaction.pk}) | ||||
| kwargs={'transaction_id': transaction.pk, }) | |||||
| self.assertEqual(success_url, r['Location']) | self.assertEqual(success_url, r['Location']) | ||||
| resp = self.client.get(success_url) | resp = self.client.get(success_url) | ||||
| self.assertEqual(200, resp.status_code) | self.assertEqual(200, resp.status_code) | ||||
| def test_bank(self): | def test_bank(self): | ||||
| sig_receiver = mock.Mock() | sig_receiver = mock.Mock() | ||||
| signals.subscription_activated.connect(sig_receiver) | signals.subscription_activated.connect(sig_receiver) | ||||
| self.assertEqual(0, models.Subscription.objects.count()) | self.assertEqual(0, models.Subscription.objects.count()) | ||||
| self.client.force_login(self.user) | self.client.force_login(self.user) | ||||
| url = reverse('looper:checkout', kwargs={'plan_id': 2, 'plan_variation_id': 6}) | url = reverse('looper:checkout', kwargs={'plan_id': 2, 'plan_variation_id': 6}) | ||||
| resp = self.client.post(url, data={ | resp = self.client.post(url, data={**self.valid_payload, 'gateway': 'bank'}) | ||||
| **self.valid_payload, | |||||
| 'gateway': 'bank', | |||||
| }) | |||||
| self.assertEqual(302, resp.status_code, resp.content.decode()) | self.assertEqual(302, resp.status_code, resp.content.decode()) | ||||
| sig_receiver.assert_not_called() | sig_receiver.assert_not_called() | ||||
| created_subs = models.Subscription.objects.first() | created_subs = models.Subscription.objects.first() | ||||
| self.assertIsNotNone(created_subs) | self.assertIsNotNone(created_subs) | ||||
| self.assertEqual('on-hold', created_subs.status) | self.assertEqual('on-hold', created_subs.status) | ||||
| order = created_subs.latest_order() | order = created_subs.latest_order() | ||||
| assert order is not None | assert order is not None | ||||
| self.assertEqual('created', order.status) | self.assertEqual('created', order.status) | ||||
| self.assertIsNone(created_subs.latest_order().latest_transaction()) | self.assertIsNone(created_subs.latest_order().latest_transaction()) | ||||
| for key in self.client.session.keys(): | for key in self.client.session.keys(): | ||||
| self.assertFalse(key.startswith('PAYMENT_GATEWAY_'), | self.assertFalse( | ||||
| f'Did not expect {key!r} in the session') | key.startswith('PAYMENT_GATEWAY_'), f'Did not expect {key!r} in the session' | ||||
| ) | |||||
| # We should now be able to get the 'done' view for this order. | # We should now be able to get the 'done' view for this order. | ||||
| success_url = reverse('looper:transactionless_checkout_done', | success_url = reverse( | ||||
| kwargs={'gateway_name': 'bank', 'pk': order.pk, }) | 'looper:transactionless_checkout_done', kwargs={'gateway_name': 'bank', 'pk': order.pk} | ||||
| ) | |||||
| self.assertEqual(success_url, resp['Location']) | self.assertEqual(success_url, resp['Location']) | ||||
| resp = self.client.get(success_url) | resp = self.client.get(success_url) | ||||
| self.assertEqual(200, resp.status_code) | self.assertEqual(200, resp.status_code) | ||||
| def _create_existing_subscription(self) -> typing.Tuple[dict, models.Subscription]: | def _create_existing_subscription(self) -> typing.Tuple[dict, models.Subscription]: | ||||
| # Do a bank payment; otherwise BrainTree detects too-fast, too-similar | # Do a bank payment; otherwise BrainTree detects too-fast, too-similar | ||||
| # transactions and flags them as duplicates. | # transactions and flags them as duplicates. | ||||
| payload = { | payload = { | ||||
| ▲ Show 20 Lines • Show All 95 Lines • ▼ Show 20 Lines | def test_recaptcha_unreachable(self): | ||||
| # Nothing mocked, so every HTTP request gives a connection error. | # Nothing mocked, so every HTTP request gives a connection error. | ||||
| r = self._do_recaptcha_protected_request() | r = self._do_recaptcha_protected_request() | ||||
| self.assertEqual(400, r.status_code, r.content.decode()) | self.assertEqual(400, r.status_code, r.content.decode()) | ||||
| self.assertIn('CAPTCHA', r.reason_phrase) | self.assertIn('CAPTCHA', r.reason_phrase) | ||||
| self.assertEqual(1, len(self.httpmock.calls)) | self.assertEqual(1, len(self.httpmock.calls)) | ||||
| @httpmock.activate | @httpmock.activate | ||||
| def test_recaptcha_invalid(self): | def test_recaptcha_invalid(self): | ||||
| self._mock_recaptcha({ | self._mock_recaptcha( | ||||
| { | |||||
| 'success': False, | 'success': False, | ||||
| 'challenge_ts': '2020-02-06T15:02:22+01:00', | 'challenge_ts': '2020-02-06T15:02:22+01:00', | ||||
| 'hostname': 'testserver', | 'hostname': 'testserver', | ||||
| 'error-codes': ['invalid-input-response'], | 'error-codes': ['invalid-input-response'], | ||||
| }) | } | ||||
| ) | |||||
| r = self._do_recaptcha_protected_request() | r = self._do_recaptcha_protected_request() | ||||
| self.assertEqual(400, r.status_code, r.content.decode()) | self.assertEqual(400, r.status_code, r.content.decode()) | ||||
| self.assertIn('CAPTCHA', r.reason_phrase) | self.assertIn('CAPTCHA', r.reason_phrase) | ||||
| self.assertEqual(1, len(self.httpmock.calls)) | self.assertEqual(1, len(self.httpmock.calls)) | ||||
| @httpmock.activate | @httpmock.activate | ||||
| def test_recaptcha_missing(self): | def test_recaptcha_missing(self): | ||||
| r = self._do_recaptcha_protected_request(client_code='') | r = self._do_recaptcha_protected_request(client_code='') | ||||
| self.assertEqual(400, r.status_code, r.content.decode()) | self.assertEqual(400, r.status_code, r.content.decode()) | ||||
| self.assertIn('CAPTCHA', r.reason_phrase) | self.assertIn('CAPTCHA', r.reason_phrase) | ||||
| self.assertEqual(0, len(self.httpmock.calls)) | self.assertEqual(0, len(self.httpmock.calls)) | ||||
| @httpmock.activate | @httpmock.activate | ||||
| def test_recaptcha_success_for_wrong_host(self): | def test_recaptcha_success_for_wrong_host(self): | ||||
| self._mock_recaptcha({ | self._mock_recaptcha( | ||||
| { | |||||
| 'success': True, | 'success': True, | ||||
| 'challenge_ts': '2020-02-06T15:02:22+01:00', | 'challenge_ts': '2020-02-06T15:02:22+01:00', | ||||
| 'hostname': 'funny.blender.org', | 'hostname': 'funny.blender.org', | ||||
| }) | } | ||||
| ) | |||||
| r = self._do_recaptcha_protected_request() | r = self._do_recaptcha_protected_request() | ||||
| self.assertEqual(400, r.status_code, r.content.decode()) | self.assertEqual(400, r.status_code, r.content.decode()) | ||||
| self.assertIn('CAPTCHA', r.reason_phrase) | self.assertIn('CAPTCHA', r.reason_phrase) | ||||
| self.assertEqual(1, len(self.httpmock.calls)) | self.assertEqual(1, len(self.httpmock.calls)) | ||||
| @httpmock.activate | @httpmock.activate | ||||
| def test_recaptcha_happy(self): | def test_recaptcha_happy(self): | ||||
| self._mock_recaptcha({ | self._mock_recaptcha( | ||||
| { | |||||
| 'success': True, | 'success': True, | ||||
| 'challenge_ts': '2020-02-06T15:02:22+01:00', | 'challenge_ts': '2020-02-06T15:02:22+01:00', | ||||
| 'hostname': 'testserver', | 'hostname': 'testserver', | ||||
| }) | } | ||||
| ) | |||||
| r = self._do_recaptcha_protected_request() | r = self._do_recaptcha_protected_request() | ||||
| self.assertEqual(302, r.status_code, r.content.decode()) | self.assertEqual(302, r.status_code, r.content.decode()) | ||||
| self.assertEqual(1, len(self.httpmock.calls)) | self.assertEqual(1, len(self.httpmock.calls)) | ||||
| @override_settings(GOOGLE_RECAPTCHA_SECRET_KEY='') | @override_settings(GOOGLE_RECAPTCHA_SECRET_KEY='') | ||||
| @httpmock.activate | @httpmock.activate | ||||
| def test_recaptcha_deactivated(self): | def test_recaptcha_deactivated(self): | ||||
| r = self._do_recaptcha_protected_request() | r = self._do_recaptcha_protected_request() | ||||
| Show All 21 Lines | def setUp(self): | ||||
| self.assertEqual(r.status_code, 200) | self.assertEqual(r.status_code, 200) | ||||
| def test_processor_declined(self): | def test_processor_declined(self): | ||||
| # The rest of the test assumes there aren't any order/subscription/transaction objects yet. | # The rest of the test assumes there aren't any order/subscription/transaction objects yet. | ||||
| self.assertEqual(0, models.Subscription.objects.count()) | self.assertEqual(0, models.Subscription.objects.count()) | ||||
| self.assertEqual(0, models.Order.objects.count()) | self.assertEqual(0, models.Order.objects.count()) | ||||
| self.assertEqual(0, models.Transaction.objects.count()) | self.assertEqual(0, models.Transaction.objects.count()) | ||||
| with mock.patch('looper.gateways.MockableGateway.customer_create') as mock_cc, \ | with mock.patch('looper.gateways.MockableGateway.customer_create') as mock_cc, mock.patch( | ||||
| mock.patch('looper.gateways.MockableGateway.payment_method_create') as mock_pmc, \ | 'looper.gateways.MockableGateway.payment_method_create' | ||||
| mock.patch('looper.gateways.MockableGateway.transact_sale') as mock_ts: | ) as mock_pmc, mock.patch('looper.gateways.MockableGateway.transact_sale') as mock_ts: | ||||
| mock_cc.return_value = 'mock-customer-id' | mock_cc.return_value = 'mock-customer-id' | ||||
| mock_paymeth = mock.Mock(spec=gateways.PaymentMethodInfo) | mock_paymeth = mock.Mock(spec=gateways.PaymentMethodInfo) | ||||
| mock_paymeth.token = 'mock-payment-token' | mock_paymeth.token = 'mock-payment-token' | ||||
| mock_paymeth.recognisable_name.return_value = 'mock-recognisable-name' | mock_paymeth.recognisable_name.return_value = 'mock-recognisable-name' | ||||
| mock_paymeth.type_for_database.return_value = 'cc' | mock_paymeth.type_for_database.return_value = 'cc' | ||||
| mock_pmc.return_value = mock_paymeth | mock_pmc.return_value = mock_paymeth | ||||
| Show All 13 Lines | def test_processor_declined(self): | ||||
| subs_pk = models.Subscription.objects.first().pk | subs_pk = models.Subscription.objects.first().pk | ||||
| order_pk = models.Order.objects.first().pk | order_pk = models.Order.objects.first().pk | ||||
| self.assertIn(f'<input type="hidden" name="subscription_pk" value="{subs_pk}"', content) | self.assertIn(f'<input type="hidden" name="subscription_pk" value="{subs_pk}"', content) | ||||
| self.assertIn(f'<input type="hidden" name="order_pk" value="{order_pk}"', content) | self.assertIn(f'<input type="hidden" name="order_pk" value="{order_pk}"', content) | ||||
| # Paying correctly should not create a new subscription + order, but just pay for | # Paying correctly should not create a new subscription + order, but just pay for | ||||
| # the one that was already created. | # the one that was already created. | ||||
| with mock.patch('looper.gateways.MockableGateway.payment_method_create') as mock_pmc, \ | with mock.patch( | ||||
| mock.patch('looper.gateways.MockableGateway.transact_sale') as mock_ts: | 'looper.gateways.MockableGateway.payment_method_create' | ||||
| ) as mock_pmc, mock.patch('looper.gateways.MockableGateway.transact_sale') as mock_ts: | |||||
| mock_cc.return_value = 'mock-customer-id' | mock_cc.return_value = 'mock-customer-id' | ||||
| mock_paymeth = mock.Mock(spec=gateways.PaymentMethodInfo) | mock_paymeth = mock.Mock(spec=gateways.PaymentMethodInfo) | ||||
| mock_paymeth.token = 'mock-another-payment-token' | mock_paymeth.token = 'mock-another-payment-token' | ||||
| mock_paymeth.recognisable_name.return_value = 'mock-another-recognisable-name' | mock_paymeth.recognisable_name.return_value = 'mock-another-recognisable-name' | ||||
| mock_paymeth.type_for_database.return_value = 'pa' | mock_paymeth.type_for_database.return_value = 'pa' | ||||
| mock_pmc.return_value = mock_paymeth | mock_pmc.return_value = mock_paymeth | ||||
| mock_ts.return_value = 'mock-transaction-id' | mock_ts.return_value = 'mock-transaction-id' | ||||
| r = self.client.post(self.checkout_url, data={ | r = self.client.post( | ||||
| **self.valid_payload, | self.checkout_url, | ||||
| 'subscription_pk': subs_pk, | data={**self.valid_payload, 'subscription_pk': subs_pk, 'order_pk': order_pk}, | ||||
| 'order_pk': order_pk, | ) | ||||
| }) | |||||
| content = r.content.decode() | content = r.content.decode() | ||||
| self.assertEqual(302, r.status_code, content) | self.assertEqual(302, r.status_code, content) | ||||
| self.assertEqual(1, models.Subscription.objects.count()) | self.assertEqual(1, models.Subscription.objects.count()) | ||||
| self.assertEqual(1, models.Order.objects.count()) | self.assertEqual(1, models.Order.objects.count()) | ||||
| self.assertEqual(2, models.Transaction.objects.count()) | self.assertEqual(2, models.Transaction.objects.count()) | ||||
| Show All 14 Lines | def test_pay_for_failed_order(self): | ||||
| url = reverse('looper:checkout_existing_order', kwargs={'order_id': order.pk}) | url = reverse('looper:checkout_existing_order', kwargs={'order_id': order.pk}) | ||||
| r = self.client.post(url, data=self.valid_payload) | r = self.client.post(url, data=self.valid_payload) | ||||
| self.assertEqual(302, r.status_code, r.content.decode()) | self.assertEqual(302, r.status_code, r.content.decode()) | ||||
| self.assertIsNotNone(activated_subs) | self.assertIsNotNone(activated_subs) | ||||
| self.assertEqual('active', activated_subs.status) | self.assertEqual('active', activated_subs.status) | ||||
| for key in self.client.session.keys(): | for key in self.client.session.keys(): | ||||
| self.assertFalse(key.startswith('PAYMENT_GATEWAY_'), | self.assertFalse( | ||||
| f'Did not expect {key!r} in the session') | key.startswith('PAYMENT_GATEWAY_'), f'Did not expect {key!r} in the session' | ||||
| ) | |||||
| def test_pay_by_bank(self): | def test_pay_by_bank(self): | ||||
| subs = self.create_on_hold_subscription() | subs = self.create_on_hold_subscription() | ||||
| order = subs.generate_order() | order = subs.generate_order() | ||||
| activated_subs: typing.Optional[models.Subscription] = None | activated_subs: typing.Optional[models.Subscription] = None | ||||
| @receiver(signals.subscription_activated) | @receiver(signals.subscription_activated) | ||||
| Show All 10 Lines | def test_pay_by_bank(self): | ||||
| r = self.client.post(url, data=payload) | r = self.client.post(url, data=payload) | ||||
| self.assertEqual(302, r.status_code, r.content.decode()) | self.assertEqual(302, r.status_code, r.content.decode()) | ||||
| self.assertIsNone(activated_subs) | self.assertIsNone(activated_subs) | ||||
| subs.refresh_from_db() | subs.refresh_from_db() | ||||
| self.assertEqual('on-hold', subs.status) | self.assertEqual('on-hold', subs.status) | ||||
| for key in self.client.session.keys(): | for key in self.client.session.keys(): | ||||
| self.assertFalse(key.startswith('PAYMENT_GATEWAY_'), | self.assertFalse( | ||||
| f'Did not expect {key!r} in the session') | key.startswith('PAYMENT_GATEWAY_'), f'Did not expect {key!r} in the session' | ||||
| ) | |||||
| class DefaultPlanVariationTest(AbstractCheckoutTestCase): | class DefaultPlanVariationTest(AbstractCheckoutTestCase): | ||||
| url = reverse_lazy('looper:checkout_new', kwargs={'plan_id': 2}) | url = reverse_lazy('looper:checkout_new', kwargs={'plan_id': 2}) | ||||
| def test_euro_ipv6(self): | def test_euro_ipv6(self): | ||||
| from .test_preferred_currency import EURO_IPV6 | from .test_preferred_currency import EURO_IPV6 | ||||
| Show All 18 Lines | |||||