From 7ba135f26e05461091444ecbe929942ff45c2107 Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Sun, 9 Jul 2023 10:32:19 +0300 Subject: [PATCH 1/9] Move CSRF exemption decorator --- subscription/views.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/subscription/views.py b/subscription/views.py index 0c90d8b6c..1fba4d437 100644 --- a/subscription/views.py +++ b/subscription/views.py @@ -7,7 +7,6 @@ from braintree import Subscription as BraintreeSubscription from braintree import WebhookNotification from django.http import HttpRequest, HttpResponse -from django.http.response import HttpResponseBase from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator from django.views import View @@ -70,14 +69,6 @@ def handle_subscription_webhook( class SubscriptionWebhookView(View): @method_decorator(csrf_exempt) - def dispatch( - self, - request: HttpRequest, - *args: tuple, - **kwargs: dict, - ) -> HttpResponseBase: - return super().dispatch(request, *args, **kwargs) - def post( self, request: HttpRequest, From c13c6d6d0e1c050abc9e75130e1a9d50b8b404b6 Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Sun, 9 Jul 2023 11:01:34 +0300 Subject: [PATCH 2/9] Revert change --- subscription/views.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/subscription/views.py b/subscription/views.py index 1fba4d437..0c90d8b6c 100644 --- a/subscription/views.py +++ b/subscription/views.py @@ -7,6 +7,7 @@ from braintree import Subscription as BraintreeSubscription from braintree import WebhookNotification from django.http import HttpRequest, HttpResponse +from django.http.response import HttpResponseBase from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator from django.views import View @@ -69,6 +70,14 @@ def handle_subscription_webhook( class SubscriptionWebhookView(View): @method_decorator(csrf_exempt) + def dispatch( + self, + request: HttpRequest, + *args: tuple, + **kwargs: dict, + ) -> HttpResponseBase: + return super().dispatch(request, *args, **kwargs) + def post( self, request: HttpRequest, From fd3cc07291912c167f6f98e72502ae7313f3f4fe Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Sun, 9 Jul 2023 16:21:36 +0300 Subject: [PATCH 3/9] Add UserFactory --- accounts/factories.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 accounts/factories.py diff --git a/accounts/factories.py b/accounts/factories.py new file mode 100644 index 000000000..3b38917f2 --- /dev/null +++ b/accounts/factories.py @@ -0,0 +1,13 @@ +from django.contrib.auth import get_user_model + +import factory +from factory.django import DjangoModelFactory + + +class UserFactory(DjangoModelFactory): + class Meta: + model = get_user_model() + + email = factory.Faker("email") + is_staff = factory.Faker("pybool") + is_active = factory.Faker("pybool") From db4981582d2e5b998939d47207ee0a75fdd694b9 Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Sun, 9 Jul 2023 16:22:00 +0300 Subject: [PATCH 4/9] Add SubscriptionFactory --- subscription/factories.py | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 subscription/factories.py diff --git a/subscription/factories.py b/subscription/factories.py new file mode 100644 index 000000000..87f3cbae4 --- /dev/null +++ b/subscription/factories.py @@ -0,0 +1,45 @@ +import factory +from factory.django import DjangoModelFactory +from factory.fuzzy import FuzzyInteger, FuzzyDate, FuzzyChoice +from django.utils.timezone import now, timedelta +from subscription.models import ( + MagazineFormatChoices, + MagazinePriceGroupChoices, + Subscription, +) + +from accounts.factories import UserFactory + + +class SubscriptionFactory(DjangoModelFactory): + class Meta: + model = Subscription + + magazine_format = FuzzyChoice(MagazineFormatChoices.values) + price_group = FuzzyChoice(MagazinePriceGroupChoices.values) + price = FuzzyInteger(0, 100) + recurring = factory.Faker("pybool") + start_date = FuzzyDate( + start_date=now().date() - timedelta(days=365), + end_date=now().date(), + ) + end_date = FuzzyDate( + start_date=now().date(), + end_date=now().date() + timedelta(days=365), + ) + subscriber_given_name = factory.Faker("first_name") + subscriber_family_name = factory.Faker("last_name") + subscriber_organization = factory.Faker("company") + subscriber_street_address = factory.Faker("street_address") + subscriber_street_address_line_2 = factory.Faker("secondary_address") + subscriber_postal_code = factory.Faker("postcode") + subscriber_address_locality = factory.Faker("city") + subscriber_address_region = factory.Faker("state") + subscriber_address_country = factory.Faker("country") + + user = factory.SubFactory(UserFactory) + paid = factory.Faker("pybool") + + braintree_subscription_id = factory.Sequence( + lambda n: f"braintree_subscription_id_{n}", + ) From f122e7c97c1c7509dd2b7b8834da58e08185b2ca Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Sun, 9 Jul 2023 16:22:21 +0300 Subject: [PATCH 5/9] Change choices to TextChoices --- subscription/models.py | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/subscription/models.py b/subscription/models.py index a217f93dd..d0a8b1354 100644 --- a/subscription/models.py +++ b/subscription/models.py @@ -18,21 +18,19 @@ logger = logging.getLogger(__name__) -# TODO: convert to a models.TextChoices class -# e.g. in donations/models.py -MAGAZINE_FORMAT_CHOICES = [ - ("pdf", "PDF"), - ("print", "Print"), - ("print_and_pdf", "Print and PDF"), -] -# TODO: convert to a models.TextChoices class -# e.g. in donations/models.py -MAGAZINE_PRICE_GROUP_CHOICES = [ - ("normal", "Normal"), - ("true_cost", "True cost"), - ("low_income", "Low income"), - ("international", "International"), -] + +class MagazineFormatChoices(models.TextChoices): + PDF = "pdf", "PDF" + PRINT = "print", "Print" + PRINT_AND_PDF = "print_and_pdf", "Print and PDF" + + +class MagazinePriceGroupChoices(models.TextChoices): + NORMAL = "normal", "Normal" + TRUE_COST = "true_cost", "True cost" + LOW_INCOME = "low_income", "Low income" + INTERNATIONAL = "international", "International" + SUBSCRIPTION_PRICE_COMPONENTS = { "normal": { @@ -103,12 +101,12 @@ def process_subscription_form( class Subscription(models.Model): magazine_format = models.CharField( max_length=255, - choices=MAGAZINE_FORMAT_CHOICES, + choices=MagazineFormatChoices.choices, default="pdf", ) price_group = models.CharField( max_length=255, - choices=MAGAZINE_PRICE_GROUP_CHOICES, + choices=MagazinePriceGroupChoices.choices, default="normal", ) price = models.IntegerField( From dee085a345cdd80359196eeaad5a84042929ed4f Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Sun, 9 Jul 2023 16:22:44 +0300 Subject: [PATCH 6/9] Name the Braintree subscription webhook URL --- subscription/urls.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/subscription/urls.py b/subscription/urls.py index e097ae221..6a2eb6884 100644 --- a/subscription/urls.py +++ b/subscription/urls.py @@ -3,5 +3,9 @@ from subscription.views import SubscriptionWebhookView urlpatterns = [ - path("braintree-subscription-webhook/", SubscriptionWebhookView.as_view()), + path( + "braintree-subscription-webhook/", + SubscriptionWebhookView.as_view(), + name="braintree-subscription-webhook", + ), ] From e6d6fadcbbfe7dafe36ab1a25f6c9a7200fec1ec Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Sun, 9 Jul 2023 16:25:03 +0300 Subject: [PATCH 7/9] Add subscription webhook test_csrf_exempt --- subscription/tests.py | 58 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/subscription/tests.py b/subscription/tests.py index 9d49e8e50..174e466d4 100644 --- a/subscription/tests.py +++ b/subscription/tests.py @@ -1,8 +1,12 @@ import datetime import braintree -from django.test import TestCase +from django.test import TestCase, Client +from django.urls import reverse +import json +from unittest.mock import Mock, patch from accounts.models import User +from subscription.factories import SubscriptionFactory from subscription.models import Subscription from .views import handle_subscription_webhook @@ -146,3 +150,55 @@ def test_subscription_str(self) -> None: def tearDown(self) -> None: self.user.delete() return super().tearDown() + + +class SubscriptionWebhookViewTests(TestCase): + def setUp(self) -> None: + self.subscription = SubscriptionFactory( + braintree_subscription_id="test_subscription_id", + ) + + self.client = Client() + self.url = reverse("braintree-subscription-webhook") + + self.webhook_notification = { + "bt_signature": "signature", + "bt_payload": "payload", + } + + @patch("subscription.views.braintree_gateway.webhook_notification.parse") + def test_csrf_exempt( + self, + mock_parse: Mock, + ) -> None: + # Get the current date and time + now = datetime.datetime.now() + one_year_later = now + datetime.timedelta(days=365) + + # Set paid_through_date to one year from now + mock_braintree_subscription = Mock() + mock_braintree_subscription.id = self.subscription.braintree_subscription_id # type: ignore # noqa: E501 + mock_braintree_subscription.paid_through_date = one_year_later + + mock_webhook_notification = Mock() + mock_webhook_notification.kind = "subscription_charged_successfully" + mock_webhook_notification.subscription = mock_braintree_subscription + + # mock parse method to return the mock notification + mock_parse.return_value = mock_webhook_notification + + csrf_client = Client(enforce_csrf_checks=True) + response = csrf_client.post( + self.url, + data=json.dumps(self.webhook_notification), + content_type="application/json", + ) + + # assert that parse was called with the correct arguments + mock_parse.assert_called_once_with( + self.webhook_notification["bt_signature"], + self.webhook_notification["bt_payload"], + ) + + # check the response status code + self.assertEqual(response.status_code, 200) From 2903180621f577d890ca5009f65befbc1db3aee2 Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Sun, 9 Jul 2023 17:01:33 +0300 Subject: [PATCH 8/9] Add test_subscription_end_date_update_with_paid_through_date --- subscription/tests.py | 46 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/subscription/tests.py b/subscription/tests.py index 174e466d4..49d9a7119 100644 --- a/subscription/tests.py +++ b/subscription/tests.py @@ -8,7 +8,7 @@ from accounts.models import User from subscription.factories import SubscriptionFactory from subscription.models import Subscription -from .views import handle_subscription_webhook +from .views import GRACE_PERIOD_DAYS, handle_subscription_webhook class SubscriptionWebhookTestCase(TestCase): @@ -202,3 +202,47 @@ def test_csrf_exempt( # check the response status code self.assertEqual(response.status_code, 200) + + @patch("subscription.views.braintree_gateway.webhook_notification.parse") + def test_subscription_end_date_updated_with_paid_through_date( + self, + mock_parse: Mock, + ) -> None: + # Get the current date without time + today = datetime.date.today() + one_year_later = today + datetime.timedelta(days=365) + + # Set paid_through_date to one year from now + mock_braintree_subscription = Mock() + mock_braintree_subscription.id = self.subscription.braintree_subscription_id # type: ignore # noqa: E501 + mock_braintree_subscription.paid_through_date = one_year_later + + mock_webhook_notification = Mock() + mock_webhook_notification.kind = "subscription_charged_successfully" + mock_webhook_notification.subscription = mock_braintree_subscription + + # mock parse method to return the mock notification + mock_parse.return_value = mock_webhook_notification + + csrf_client = Client(enforce_csrf_checks=True) + response = csrf_client.post( + self.url, + data=json.dumps(self.webhook_notification), + content_type="application/json", + ) + + # assert that parse was called with the correct arguments + mock_parse.assert_called_once_with( + self.webhook_notification["bt_signature"], + self.webhook_notification["bt_payload"], + ) + + # check the response status code + self.assertEqual(response.status_code, 200) + + # Refresh subscription from db + self.subscription.refresh_from_db() + + # check that the end_date is updated + expected_end_date = one_year_later + GRACE_PERIOD_DAYS + self.assertEqual(self.subscription.end_date, expected_end_date) From 4b3df54a65e4a746bdf29a42ad87057fede059b7 Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Sun, 9 Jul 2023 17:20:09 +0300 Subject: [PATCH 9/9] Add test_subscription_end_date_updated_without_paid_through_date --- subscription/tests.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/subscription/tests.py b/subscription/tests.py index 49d9a7119..081da3437 100644 --- a/subscription/tests.py +++ b/subscription/tests.py @@ -246,3 +246,46 @@ def test_subscription_end_date_updated_with_paid_through_date( # check that the end_date is updated expected_end_date = one_year_later + GRACE_PERIOD_DAYS self.assertEqual(self.subscription.end_date, expected_end_date) + + @patch("subscription.views.braintree_gateway.webhook_notification.parse") + def test_subscription_end_date_updated_without_paid_through_date( + self, + mock_parse: Mock, + ) -> None: + # Calculate the expected end_date + one_year_with_grace_period = datetime.timedelta(days=365) + GRACE_PERIOD_DAYS + expected_end_date = self.subscription.end_date + one_year_with_grace_period + + # Set up the Braintree subscription mock without paid_through_date + mock_braintree_subscription = Mock() + mock_braintree_subscription.id = self.subscription.braintree_subscription_id # type: ignore # noqa: E501 + mock_braintree_subscription.paid_through_date = None + + mock_webhook_notification = Mock() + mock_webhook_notification.kind = "subscription_charged_successfully" + mock_webhook_notification.subscription = mock_braintree_subscription + + # mock parse method to return the mock notification + mock_parse.return_value = mock_webhook_notification + + csrf_client = Client(enforce_csrf_checks=True) + response = csrf_client.post( + self.url, + data=json.dumps(self.webhook_notification), + content_type="application/json", + ) + + # assert that parse was called with the correct arguments + mock_parse.assert_called_once_with( + self.webhook_notification["bt_signature"], + self.webhook_notification["bt_payload"], + ) + + # check the response status code + self.assertEqual(response.status_code, 200) + + # Refresh subscription from db + self.subscription.refresh_from_db() + + # check that the end_date is updated + self.assertEqual(self.subscription.end_date, expected_end_date)