diff --git a/remarkbox/templates/setup-namespace.j2 b/remarkbox/templates/setup-namespace.j2 index 871d502..f867674 100644 --- a/remarkbox/templates/setup-namespace.j2 +++ b/remarkbox/templates/setup-namespace.j2 @@ -1,5 +1,4 @@ {% extends request.base_funnel_template -%} -{%- import 'snippets/stripe.j2' as stripe with context -%} {% import 'snippets/forms.j2' as forms with context %} {% block title %}Let's Rock | {{ request.domain }}{%- endblock -%} diff --git a/remarkbox/templates/user-settings.j2 b/remarkbox/templates/user-settings.j2 index 5e89141..ad63203 100644 --- a/remarkbox/templates/user-settings.j2 +++ b/remarkbox/templates/user-settings.j2 @@ -1,5 +1,4 @@ {% extends request.base_template -%} -{%- import 'snippets/stripe.j2' as stripe with context -%} {% block title %}{{ the_title }} | {{ request.domain }}{%- endblock -%} {% block content -%} diff --git a/remarkbox/tests/test_models.py b/remarkbox/tests/test_models.py index b5ffda9..3be883e 100644 --- a/remarkbox/tests/test_models.py +++ b/remarkbox/tests/test_models.py @@ -1,6 +1,6 @@ import unittest -import mock +from unittest import mock from remarkbox.models.user import User, is_user_name_valid diff --git a/remarkbox/tests/test_stripe.py b/remarkbox/tests/test_stripe.py new file mode 100644 index 0000000..025ba49 --- /dev/null +++ b/remarkbox/tests/test_stripe.py @@ -0,0 +1,286 @@ +"""Tests for Stripe Checkout integration.""" + +import unittest +from unittest.mock import patch, MagicMock, PropertyMock + +from remarkbox.stripe.checkout import ( + configure_stripe, + create_checkout_session, + retrieve_checkout_session, + verify_webhook_signature, +) + +from remarkbox.models.payment import Payment, get_payment_by_session_id, create_payment +from remarkbox.models.user import User + + +class TestStripeCheckoutModule(unittest.TestCase): + """Unit tests for stripe/checkout.py""" + + def test_configure_stripe(self): + """Test that configure_stripe sets the API key.""" + with patch('remarkbox.stripe.checkout.stripe') as mock_stripe: + configure_stripe("sk_test_123") + self.assertEqual(mock_stripe.api_key, "sk_test_123") + + def test_create_checkout_session_minimum_amount(self): + """Test that minimum amount is enforced.""" + session, error = create_checkout_session( + amount_cents=50, # Less than $1.00 + payment_type="pay_what_you_want", + ) + self.assertIsNone(session) + self.assertEqual(error, "Minimum payment amount is $1.00") + + def test_create_checkout_session_invalid_payment_type(self): + """Test that invalid payment type returns error.""" + session, error = create_checkout_session( + amount_cents=1000, + payment_type="invalid_type", + ) + self.assertIsNone(session) + self.assertIn("Invalid payment type", error) + + @patch('remarkbox.stripe.checkout.stripe.checkout.Session.create') + def test_create_checkout_session_pay_what_you_want(self, mock_create): + """Test creating a pay-what-you-want checkout session.""" + mock_session = MagicMock() + mock_session.id = "cs_test_123" + mock_session.url = "https://checkout.stripe.com/pay/cs_test_123" + mock_create.return_value = mock_session + + session, error = create_checkout_session( + amount_cents=1000, + payment_type="pay_what_you_want", + email="test@example.com", + success_url="https://example.com/success", + cancel_url="https://example.com/cancel", + ) + + self.assertIsNone(error) + self.assertEqual(session.id, "cs_test_123") + + # Verify the call was made with correct parameters + call_args = mock_create.call_args + self.assertEqual(call_args.kwargs["mode"], "payment") + self.assertEqual(call_args.kwargs["payment_method_types"], ["card"]) + self.assertEqual(call_args.kwargs["customer_email"], "test@example.com") + self.assertEqual(call_args.kwargs["metadata"]["payment_type"], "pay_what_you_want") + + @patch('remarkbox.stripe.checkout.stripe.checkout.Session.create') + def test_create_checkout_session_annual(self, mock_create): + """Test creating an annual subscription checkout session.""" + mock_session = MagicMock() + mock_session.id = "cs_test_annual" + mock_create.return_value = mock_session + + session, error = create_checkout_session( + amount_cents=12000, # $120 + payment_type="annual", + duration_months=12, + email="test@example.com", + success_url="https://example.com/success", + cancel_url="https://example.com/cancel", + ) + + self.assertIsNone(error) + self.assertEqual(session.id, "cs_test_annual") + + call_args = mock_create.call_args + self.assertEqual(call_args.kwargs["metadata"]["payment_type"], "annual") + self.assertEqual(call_args.kwargs["metadata"]["duration_months"], "12") + + @patch('remarkbox.stripe.checkout.stripe.checkout.Session.create') + def test_create_checkout_session_top_up(self, mock_create): + """Test creating a top-up checkout session.""" + mock_session = MagicMock() + mock_session.id = "cs_test_topup" + mock_create.return_value = mock_session + + session, error = create_checkout_session( + amount_cents=2500, # $25 + payment_type="top_up", + duration_months=0, + success_url="https://example.com/success", + cancel_url="https://example.com/cancel", + ) + + self.assertIsNone(error) + self.assertEqual(session.id, "cs_test_topup") + + @patch('remarkbox.stripe.checkout.stripe.checkout.Session.create') + def test_create_checkout_session_stripe_error(self, mock_create): + """Test handling Stripe errors during session creation.""" + import stripe + mock_create.side_effect = stripe.error.StripeError("API error") + + session, error = create_checkout_session( + amount_cents=1000, + payment_type="pay_what_you_want", + success_url="https://example.com/success", + cancel_url="https://example.com/cancel", + ) + + self.assertIsNone(session) + self.assertIn("API error", error) + + @patch('remarkbox.stripe.checkout.stripe.checkout.Session.retrieve') + def test_retrieve_checkout_session_success(self, mock_retrieve): + """Test retrieving a checkout session.""" + mock_session = MagicMock() + mock_session.id = "cs_test_123" + mock_session.payment_status = "paid" + mock_retrieve.return_value = mock_session + + session, error = retrieve_checkout_session("cs_test_123") + + self.assertIsNone(error) + self.assertEqual(session.id, "cs_test_123") + self.assertEqual(session.payment_status, "paid") + + @patch('remarkbox.stripe.checkout.stripe.checkout.Session.retrieve') + def test_retrieve_checkout_session_not_found(self, mock_retrieve): + """Test retrieving a non-existent checkout session.""" + import stripe + mock_retrieve.side_effect = stripe.error.StripeError("No such session") + + session, error = retrieve_checkout_session("cs_invalid") + + self.assertIsNone(session) + self.assertIn("No such session", error) + + @patch('remarkbox.stripe.checkout.stripe.Webhook.construct_event') + def test_verify_webhook_signature_success(self, mock_construct): + """Test successful webhook signature verification.""" + mock_event = MagicMock() + mock_event.type = "checkout.session.completed" + mock_construct.return_value = mock_event + + event, error = verify_webhook_signature( + payload=b'{"test": "data"}', + sig_header="sig_header", + webhook_secret="whsec_test", + ) + + self.assertIsNone(error) + self.assertEqual(event.type, "checkout.session.completed") + + @patch('remarkbox.stripe.checkout.stripe.Webhook.construct_event') + def test_verify_webhook_signature_invalid(self, mock_construct): + """Test invalid webhook signature.""" + import stripe + mock_construct.side_effect = stripe.error.SignatureVerificationError( + "Invalid signature", "sig_header" + ) + + event, error = verify_webhook_signature( + payload=b'{"test": "data"}', + sig_header="bad_sig", + webhook_secret="whsec_test", + ) + + self.assertIsNone(event) + self.assertEqual(error, "Invalid signature") + + +class TestPaymentModel(unittest.TestCase): + """Unit tests for Payment model.""" + + @patch("remarkbox.models.user.is_user_name_available", MagicMock(return_value=True)) + def setUp(self): + self.user = User("test@example.com") + self.user.id = "test-user-id-123" + + def test_payment_creation(self): + """Test creating a Payment object.""" + payment = Payment( + user=self.user, + stripe_session_id="cs_test_123", + payment_type="pay_what_you_want", + amount_cents=1000, + duration_months=0, + ) + + self.assertEqual(payment.stripe_session_id, "cs_test_123") + self.assertEqual(payment.payment_type, "pay_what_you_want") + self.assertEqual(payment.amount_cents, 1000) + self.assertEqual(payment.status, "pending") + self.assertIsNotNone(payment.created_timestamp) + self.assertIsNone(payment.completed_timestamp) + + def test_payment_amount_dollars(self): + """Test amount_dollars property.""" + payment = Payment( + user=self.user, + stripe_session_id="cs_test_123", + payment_type="pay_what_you_want", + amount_cents=1250, + duration_months=0, + ) + + self.assertEqual(payment.amount_dollars, 12.50) + + def test_payment_mark_completed(self): + """Test marking payment as completed.""" + payment = Payment( + user=self.user, + stripe_session_id="cs_test_123", + payment_type="annual", + amount_cents=12000, + duration_months=12, + ) + + self.assertEqual(payment.status, "pending") + self.assertIsNone(payment.completed_timestamp) + + payment.mark_completed() + + self.assertEqual(payment.status, "completed") + self.assertIsNotNone(payment.completed_timestamp) + + def test_payment_mark_failed(self): + """Test marking payment as failed.""" + payment = Payment( + user=self.user, + stripe_session_id="cs_test_123", + payment_type="top_up", + amount_cents=2500, + duration_months=0, + ) + + payment.mark_failed() + + self.assertEqual(payment.status, "failed") + + +class TestPayWhatYouCanModel(unittest.TestCase): + """Unit tests for PayWhatYouCan model.""" + + @patch("remarkbox.models.user.is_user_name_available", MagicMock(return_value=True)) + def setUp(self): + self.user = User("test@example.com") + + def test_pay_what_you_can_creation(self): + """Test creating a PayWhatYouCan preference.""" + from remarkbox.models import PayWhatYouCan + + pwc = PayWhatYouCan(self.user, "yearly", 100) + + self.assertEqual(pwc.frequency, "yearly") + self.assertEqual(pwc.amount, 100) + # contributions defaults to 0 in DB but may be None before flush + self.assertIn(pwc.contributions, [0, None]) + self.assertIsNotNone(pwc.created_timestamp) + + def test_pay_what_you_can_update(self): + """Test updating PayWhatYouCan preferences.""" + from remarkbox.models import PayWhatYouCan + + pwc = PayWhatYouCan(self.user, "once", 50) + original_timestamp = pwc.updated_timestamp + + pwc.update("yearly", 100) + + self.assertEqual(pwc.frequency, "yearly") + self.assertEqual(pwc.amount, 100) + self.assertGreaterEqual(pwc.updated_timestamp, original_timestamp) diff --git a/remarkbox/tests/test_views.py b/remarkbox/tests/test_views.py index 65e3a72..79926f4 100644 --- a/remarkbox/tests/test_views.py +++ b/remarkbox/tests/test_views.py @@ -17,8 +17,8 @@ from remarkbox.lib.notify import deliver_scheduled_notifications from pyramid.paster import get_appsettings -import mock -from mock import patch, call +from unittest import mock +from unittest.mock import patch, call import re try: @@ -80,7 +80,7 @@ class UnauthenticatedFunctionalTests(FunctionalTests): def test_billing_redirects(self): redirect_res = self.testapp.get("/billing", status=302) res = redirect_res.follow() - self.assertTrue(b"Thank you for helping us out, Please verify your email in the form below!" in res.body) + self.assertTrue(b"Please verify your email to access billing." in res.body) def test_user_settings_redirects(self): redirect_res = self.testapp.get("/u/settings", status=302) @@ -476,3 +476,141 @@ class AuthenticatedFunctionalTests(FunctionalTests): # make sure our daily notification was sent. self.assertEqual(notifications[0].frequency, "daily") self.assertTrue(notifications[0].sent) + + def test_billing_page_loads(self): + """Test that the billing page loads for authenticated users.""" + self._log_in_test_user(self.test_creds1) + res = self.testapp.get("/billing", status=200) + self.assertIn(b"Pay What You Can", res.body) + self.assertIn(b"Annual Subscription", res.body) + self.assertIn(b"Top Up", res.body) + + def test_pay_what_you_can_preference(self): + """Test saving pay-what-you-can preferences.""" + self._log_in_test_user(self.test_creds1) + + # Save preferences + redirect_res = self.testapp.post( + "/pay-what-you-can", + { + "frequency": "yearly", + "amount": "50", + "csrf_token": self.csrf, + }, + status=302, + ) + res = redirect_res.follow() + self.assertIn(b"Your contribution preferences have been saved", res.body) + + # Verify billing page loads successfully + billing_res = self.testapp.get("/billing", status=200) + self.assertIn(b"Pay What You Can", billing_res.body) + + def test_pay_what_you_can_update_preference(self): + """Test updating pay-what-you-can preferences.""" + self._log_in_test_user(self.test_creds1) + + # Set initial preferences + self.testapp.post( + "/pay-what-you-can", + { + "frequency": "once", + "amount": "25", + "csrf_token": self.csrf, + }, + ) + + # Update preferences + redirect_res = self.testapp.post( + "/pay-what-you-can", + { + "frequency": "yearly", + "amount": "100", + "csrf_token": self.csrf, + }, + status=302, + ) + res = redirect_res.follow() + self.assertIn(b"Your contribution preferences have been saved", res.body) + + def test_pay_what_you_can_missing_fields(self): + """Test pay-what-you-can with missing fields.""" + self._log_in_test_user(self.test_creds1) + + # Missing amount + redirect_res = self.testapp.post( + "/pay-what-you-can", + { + "frequency": "yearly", + "csrf_token": self.csrf, + }, + status=302, + ) + res = redirect_res.follow() + self.assertIn(b"You must set both frequency and amount", res.body) + + @patch("remarkbox.stripe.checkout.stripe.checkout.Session.create") + def test_create_checkout_redirects_to_stripe(self, mock_create): + """Test that create-checkout redirects to Stripe.""" + mock_session = mock.MagicMock() + mock_session.id = "cs_test_123" + mock_session.url = "https://checkout.stripe.com/pay/cs_test_123" + mock_create.return_value = mock_session + + self._log_in_test_user(self.test_creds1) + + redirect_res = self.testapp.post( + "/billing/checkout", + { + "payment_type": "pay_what_you_want", + "amount": "25", + "duration_months": "0", + "csrf_token": self.csrf, + }, + status=302, + ) + + # Should redirect to Stripe Checkout + self.assertIn("checkout.stripe.com", redirect_res.location) + + def test_create_checkout_minimum_amount(self): + """Test that checkout enforces minimum amount.""" + self._log_in_test_user(self.test_creds1) + + redirect_res = self.testapp.post( + "/billing/checkout", + { + "payment_type": "pay_what_you_want", + "amount": "0.50", + "duration_months": "0", + "csrf_token": self.csrf, + }, + status=302, + ) + res = redirect_res.follow() + self.assertIn(b"Minimum payment is $1.00", res.body) + + def test_create_checkout_invalid_amount(self): + """Test checkout with invalid amount.""" + self._log_in_test_user(self.test_creds1) + + redirect_res = self.testapp.post( + "/billing/checkout", + { + "payment_type": "pay_what_you_want", + "amount": "not-a-number", + "duration_months": "0", + "csrf_token": self.csrf, + }, + status=302, + ) + res = redirect_res.follow() + self.assertIn(b"Invalid amount specified", res.body) + + def test_billing_success_missing_session(self): + """Test billing success without session_id.""" + self._log_in_test_user(self.test_creds1) + + redirect_res = self.testapp.get("/billing/success", status=302) + res = redirect_res.follow() + self.assertIn(b"Missing session information", res.body)