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") 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}", + ) 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( diff --git a/subscription/tests.py b/subscription/tests.py index 9d49e8e50..081da3437 100644 --- a/subscription/tests.py +++ b/subscription/tests.py @@ -1,10 +1,14 @@ 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 +from .views import GRACE_PERIOD_DAYS, handle_subscription_webhook class SubscriptionWebhookTestCase(TestCase): @@ -146,3 +150,142 @@ 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) + + @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) + + @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) 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", + ), ]