From 5db9f40c74ceae0f6871d419aef85a3a3391d6f7 Mon Sep 17 00:00:00 2001 From: ontowhee <82607723+ontowhee@users.noreply.github.com> Date: Thu, 21 Nov 2024 13:11:55 -0800 Subject: [PATCH 1/3] Add test for stripe webhook checkout session completed --- fundraising/test_data/customer.json | 62 +++++ fundraising/test_data/payment_intent.json | 231 +++++++++++++++++++ fundraising/test_data/session_completed.json | 86 +++++++ fundraising/tests/test_views.py | 11 + 4 files changed, 390 insertions(+) create mode 100644 fundraising/test_data/customer.json create mode 100644 fundraising/test_data/payment_intent.json create mode 100644 fundraising/test_data/session_completed.json diff --git a/fundraising/test_data/customer.json b/fundraising/test_data/customer.json new file mode 100644 index 000000000..287fe47c6 --- /dev/null +++ b/fundraising/test_data/customer.json @@ -0,0 +1,62 @@ +{ + "account_balance": 0, + "address": null, + "balance": 0, + "cards": { + "data": [], + "has_more": false, + "object": "list", + "total_count": 0, + "url": "/v1/customers/cus_3MXPY5pvYMWTBf/cards" + }, + "created": 1732222262, + "currency": null, + "default_card": null, + "default_currency": null, + "default_source": null, + "delinquent": false, + "description": null, + "discount": null, + "email": "customer@email.com", + "id": "cus_3MXPY5pvYMWTBf", + "invoice_prefix": "17A431B3", + "invoice_settings": { + "custom_fields": null, + "default_payment_method": null, + "footer": null, + "rendering_options": null + }, + "livemode": false, + "metadata": {}, + "name": null, + "next_invoice_sequence": 1, + "object": "customer", + "phone": null, + "preferred_locales": [], + "shipping": null, + "sources": { + "data": [], + "has_more": false, + "object": "list", + "total_count": 0, + "url": "/v1/customers/cus_3MXPY5pvYMWTBf/sources" + }, + "subscriptions": { + "data": [], + "has_more": false, + "object": "list", + "total_count": 0, + "url": "/v1/customers/cus_3MXPY5pvYMWTBf/subscriptions" + }, + "tax_exempt": "none", + "tax_ids": { + "data": [], + "has_more": false, + "object": "list", + "total_count": 0, + "url": "/v1/customers/cus_3MXPY5pvYMWTBf/tax_ids" + }, + "tax_info": null, + "tax_info_verification": null, + "test_clock": null + } diff --git a/fundraising/test_data/payment_intent.json b/fundraising/test_data/payment_intent.json new file mode 100644 index 000000000..46d80fd2d --- /dev/null +++ b/fundraising/test_data/payment_intent.json @@ -0,0 +1,231 @@ +{ + "allowed_source_types": [ + "card" + ], + "amount": 3000, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 3000, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "charges": { + "data": [ + { + "amount": 3000, + "amount_captured": 3000, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": "txn_2MXkIN4ao19wAZWp0wQIDyAD", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": "d@b.com", + "phone": null + }, + "calculated_statement_descriptor": "DJANGOPROJECT.COM", + "captured": true, + "card": { + "address_city": null, + "address_country": null, + "address_line1": null, + "address_line1_check": null, + "address_line2": null, + "address_state": null, + "address_zip": null, + "address_zip_check": null, + "brand": "Visa", + "country": "US", + "customer": "cus_6Mf8HUzoOM5r06", + "cvc_check": null, + "dynamic_last4": null, + "exp_month": 12, + "exp_year": 2034, + "fingerprint": "xygwMO5bYmv4EmZm", + "funding": "credit", + "id": "card_6Mf8W5TmoG71AP", + "last4": "4242", + "metadata": {}, + "name": "d@b.com", + "object": "card", + "tokenization_method": null, + "wallet": null + }, + "created": 1675511560, + "currency": "usd", + "customer": "cus_6Mf8HUzoOM5r06", + "description": "Invoice 94026E9-0072", + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": null, + "failure_message": null, + "fraud_details": {}, + "id": "ch_2MXkIN4ao19wAZWp0yIS2t5T", + "invoice": "in_0MXjHW4ao19wAZWpwzZjD8Ie", + "livemode": false, + "metadata": {}, + "object": "charge", + "on_behalf_of": null, + "order": null, + "outcome": { + "network_advice_code": null, + "network_decline_code": null, + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 6, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": "pi_2MXkIN4ao19wAZWp0atVBWd2", + "payment_method": "card_6Mf8W5TmoG71AP", + "payment_method_details": { + "card": { + "amount_authorized": 3000, + "authorization_code": null, + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": null + }, + "country": "US", + "exp_month": 12, + "exp_year": 2034, + "extended_authorization": { + "status": "disabled" + }, + "fingerprint": "xygwMO5bYmv4EmZm", + "funding": "credit", + "incremental_authorization": { + "status": "unavailable" + }, + "installments": null, + "last4": "4242", + "mandate": null, + "multicapture": { + "status": "unavailable" + }, + "network": "visa", + "network_token": { + "used": false + }, + "overcapture": { + "maximum_amount_capturable": 3000, + "status": "unavailable" + }, + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "receipt_email": "d@b.com", + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/invoices/CAcaGwoZYWNjdF8xV01QNGFvMTl3QVpXcGlNb3d0QijtvP65BjIG7quOkp-6OiwWZhwfLUVCB3PgWg7lw1O_qxdCsOOzqKj3Gn7Z16NF5qMYuHEtHxhOJa6nJA?s=ap", + "refunded": false, + "refunds": { + "data": [], + "has_more": false, + "object": "list", + "total_count": 0, + "url": "/v1/charges/ch_2MXkIN4ao19wAZWp0yIS2t5T/refunds" + }, + "review": null, + "shipping": null, + "source": { + "address_city": null, + "address_country": null, + "address_line1": null, + "address_line1_check": null, + "address_line2": null, + "address_state": null, + "address_zip": null, + "address_zip_check": null, + "brand": "Visa", + "country": "US", + "customer": "cus_6Mf8HUzoOM5r06", + "cvc_check": null, + "dynamic_last4": null, + "exp_month": 12, + "exp_year": 2034, + "fingerprint": "xygwMO5bYmv4EmZm", + "funding": "credit", + "id": "card_6Mf8W5TmoG71AP", + "last4": "4242", + "metadata": {}, + "name": "d@b.com", + "object": "card", + "tokenization_method": null, + "wallet": null + }, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "paid", + "transfer_data": null, + "transfer_group": null + } + ], + "has_more": false, + "object": "list", + "total_count": 1, + "url": "/v1/charges?payment_intent=pi_2MXkIN4ao19wAZWp0atVBWd2" + }, + "client_secret": "pi_2MXkIN4ao19wAZWp0atVBWd2_secret_WX2PK2jXLxklt3dHzrTMNpVn2", + "confirmation_method": "automatic", + "created": 1675511559, + "currency": "usd", + "customer": "cus_6Mf8HUzoOM5r06", + "description": "Invoice 94026E9-0072", + "id": "pi_2MXkIN4ao19wAZWp0atVBWd2", + "invoice": "in_0MXjHW4ao19wAZWpwzZjD8Ie", + "last_payment_error": null, + "latest_charge": "ch_2MXkIN4ao19wAZWp0yIS2t5T", + "livemode": false, + "metadata": {}, + "next_action": null, + "next_source_action": null, + "object": "payment_intent", + "on_behalf_of": null, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": "d@b.com", + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": "card_6Mf8W5TmoG71AP", + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } diff --git a/fundraising/test_data/session_completed.json b/fundraising/test_data/session_completed.json new file mode 100644 index 000000000..b73fbae6f --- /dev/null +++ b/fundraising/test_data/session_completed.json @@ -0,0 +1,86 @@ +{ + "id": "evt_103MXP2kbIgHtIBFeIFqPxp7", + "object": "event", + "api_version": "2015-01-11", + "created": 1683928544, + "data": { + "object": { + "id": "cs_test_a11YYufWQzNY63zpQ6QSNRQhkUpVph4WRmzW0zWJO2znZKdVujZ0N0S22u", + "object": "checkout.session", + "after_expiration": null, + "allow_promotion_codes": null, + "amount_subtotal": 2198, + "amount_total": 2198, + "automatic_tax": { + "enabled": false, + "liability": null, + "status": null + }, + "billing_address_collection": null, + "cancel_url": null, + "client_reference_id": null, + "consent": null, + "consent_collection": null, + "created": 1679600215, + "currency": "usd", + "custom_fields": [], + "custom_text": { + "shipping_address": null, + "submit": null + }, + "customer": null, + "customer_creation": "if_required", + "customer_details": null, + "customer_email": null, + "expires_at": 1679686615, + "invoice": null, + "invoice_creation": { + "enabled": false, + "invoice_data": { + "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, + "issuer": null, + "metadata": {}, + "rendering_options": null + } + }, + "livemode": false, + "locale": null, + "metadata": {}, + "mode": "payment", + "payment_intent": null, + "payment_link": null, + "payment_method_collection": "always", + "payment_method_options": {}, + "payment_method_types": [ + "card" + ], + "payment_status": "unpaid", + "phone_number_collection": { + "enabled": false + }, + "recovered_from": null, + "setup_intent": null, + "shipping_address_collection": null, + "shipping_cost": null, + "shipping_details": null, + "shipping_options": [], + "status": "open", + "submit_type": null, + "subscription": null, + "success_url": "https://example.com/success", + "total_details": { + "amount_discount": 0, + "amount_shipping": 0, + "amount_tax": 0 + }, + "url": "https://checkout.stripe.com/c/pay/cs_test_a11YYufWQzNY63zpQ6QSNRQhkUpVph4WRmzW0zWJO2znZKdVujZ0N0S22u#fidkdWxOYHwnPyd1blpxYHZxWjA0SDdPUW5JbmFMck1wMmx9N2BLZjFEfGRUNWhqTmJ%2FM2F8bUA2SDRySkFdUV81T1BSV0YxcWJcTUJcYW5rSzN3dzBLPUE0TzRKTTxzNFBjPWZEX1NKSkxpNTVjRjN8VHE0YicpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl" + } + }, + "livemode": true, + "pending_webhooks": 1, + "request": null, + "type": "checkout.session.completed" +} diff --git a/fundraising/tests/test_views.py b/fundraising/tests/test_views.py index 9a483ce59..60dd042dd 100644 --- a/fundraising/tests/test_views.py +++ b/fundraising/tests/test_views.py @@ -14,6 +14,7 @@ from django_recaptcha.client import RecaptchaResponse from ..models import DjangoHero, Donation +from ..views import WebhookHandler class TestIndex(TestCase): @@ -253,3 +254,13 @@ def test_zero_invoice_amount(self, event): response = self.post_event() self.assertEqual(response.status_code, 201) self.assertEqual(self.donation.payment_set.count(), 0) + + @patch("stripe.Customer.retrieve") + @patch("stripe.PaymentIntent.retrieve") + def test_checkout_session_completed(self, payment_intent, customer): + customer.return_value = self.stripe_data("customer") + payment_intent.return_value = self.stripe_data("payment_intent") + event = self.stripe_data("session_completed") + WebhookHandler(event).handle() + customer.assert_called_once() + payment_intent.assert_called_once() From c4fb43446fe209f766a1383043a664c12f5ba792 Mon Sep 17 00:00:00 2001 From: ontowhee <82607723+ontowhee@users.noreply.github.com> Date: Fri, 29 Nov 2024 22:51:47 -0800 Subject: [PATCH 2/3] Add assertions to ensure email is sent and check status code. --- fundraising/test_data/customer.json | 2 +- fundraising/test_data/session_completed.json | 2 +- fundraising/tests/test_views.py | 19 ++++++++++++++----- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/fundraising/test_data/customer.json b/fundraising/test_data/customer.json index 287fe47c6..e020ab350 100644 --- a/fundraising/test_data/customer.json +++ b/fundraising/test_data/customer.json @@ -17,7 +17,7 @@ "delinquent": false, "description": null, "discount": null, - "email": "customer@email.com", + "email": "hero@djangoproject.com", "id": "cus_3MXPY5pvYMWTBf", "invoice_prefix": "17A431B3", "invoice_settings": { diff --git a/fundraising/test_data/session_completed.json b/fundraising/test_data/session_completed.json index b73fbae6f..2a69a32c6 100644 --- a/fundraising/test_data/session_completed.json +++ b/fundraising/test_data/session_completed.json @@ -28,7 +28,7 @@ "shipping_address": null, "submit": null }, - "customer": null, + "customer": "cus_3MXPY5pvYMWTBf", "customer_creation": "if_required", "customer_details": null, "customer_email": null, diff --git a/fundraising/tests/test_views.py b/fundraising/tests/test_views.py index 60dd042dd..76c85c756 100644 --- a/fundraising/tests/test_views.py +++ b/fundraising/tests/test_views.py @@ -14,7 +14,6 @@ from django_recaptcha.client import RecaptchaResponse from ..models import DjangoHero, Donation -from ..views import WebhookHandler class TestIndex(TestCase): @@ -183,7 +182,10 @@ def test_past_donations_sorted(self): class TestWebhooks(TestCase): def setUp(self): - self.hero = DjangoHero.objects.create(email="hero@djangoproject.com") + self.hero = DjangoHero.objects.create( + email="hero@djangoproject.com", + stripe_customer_id="cus_3MXPY5pvYMWTBf", + ) self.donation = Donation.objects.create( donor=self.hero, interval="monthly", @@ -257,10 +259,17 @@ def test_zero_invoice_amount(self, event): @patch("stripe.Customer.retrieve") @patch("stripe.PaymentIntent.retrieve") - def test_checkout_session_completed(self, payment_intent, customer): + @patch("stripe.Event.retrieve") + def test_checkout_session_completed(self, event, payment_intent, customer): customer.return_value = self.stripe_data("customer") payment_intent.return_value = self.stripe_data("payment_intent") - event = self.stripe_data("session_completed") - WebhookHandler(event).handle() + event.return_value = self.stripe_data("session_completed") + response = self.post_event() + self.assertEqual(response.status_code, 204) customer.assert_called_once() payment_intent.assert_called_once() + self.assertEqual(len(mail.outbox), 1) + expected_url = django_hosts_reverse( + "fundraising:manage-donations", kwargs={"hero": self.hero.id} + ) + self.assertTrue(expected_url in mail.outbox[0].body) From 46069506d37d412ffd6b206ec0cbfbd51d5a5759 Mon Sep 17 00:00:00 2001 From: ontowhee <82607723+ontowhee@users.noreply.github.com> Date: Fri, 29 Nov 2024 23:12:06 -0800 Subject: [PATCH 3/3] Add test for checkout_session_completed when the DjangoHero does not exist for the customer --- fundraising/tests/test_views.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/fundraising/tests/test_views.py b/fundraising/tests/test_views.py index 76c85c756..696f63abf 100644 --- a/fundraising/tests/test_views.py +++ b/fundraising/tests/test_views.py @@ -265,11 +265,30 @@ def test_checkout_session_completed(self, event, payment_intent, customer): payment_intent.return_value = self.stripe_data("payment_intent") event.return_value = self.stripe_data("session_completed") response = self.post_event() + self.assertEqual(response.status_code, 204) customer.assert_called_once() payment_intent.assert_called_once() + self.assertEqual(len(mail.outbox), 1) expected_url = django_hosts_reverse( "fundraising:manage-donations", kwargs={"hero": self.hero.id} ) self.assertTrue(expected_url in mail.outbox[0].body) + + self.assertEqual(DjangoHero.objects.count(), 1) + + @patch("stripe.Customer.retrieve") + @patch("stripe.PaymentIntent.retrieve") + @patch("stripe.Event.retrieve") + def test_checkout_session_completed_new_hero(self, event, payment_intent, customer): + self.hero.stripe_customer_id = "" + self.hero.save() + + customer.return_value = self.stripe_data("customer") + payment_intent.return_value = self.stripe_data("payment_intent") + event.return_value = self.stripe_data("session_completed") + response = self.post_event() + + self.assertEqual(response.status_code, 204) + self.assertEqual(DjangoHero.objects.count(), 2)