diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index 479e677..434784a 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -1626,3 +1626,316 @@ class AuthenticatedFunctionalTests(FunctionalTests): self.assertEqual( shop.comments_require_approval, initial_values["comments_require_approval"] ) + + @mock.patch("smtplib.SMTP") + def test_shop_paypal_credentials_can_be_set(self, mock_smtp): + """Test that PayPal credentials can be set on a shop via the model.""" + # Create shop using helper + shop = self._create_shop_helper( + user_creds=self.user1_creds, + shop_params=self.shop1_params, + ) + + # Set PayPal credentials directly on the model + shop.paypal_client_id = "test_client_id_abc123" + shop.paypal_secret = "test_secret_xyz789" + shop.paypal_enabled = True + self.dbsession.add(shop) + self.dbsession.flush() + transaction.manager.commit() + + # Re-query shop from database to verify persistence + shop = get_shop_by_name(self.dbsession, self.shop1_params["name"]) + + # Verify PayPal is configured + self.assertEqual(shop.paypal_client_id, "test_client_id_abc123") + self.assertEqual(shop.paypal_secret, "test_secret_xyz789") + self.assertTrue(shop.paypal_enabled) + + @mock.patch("smtplib.SMTP") + def test_paypal_create_order_requires_cart(self, mock_smtp): + """Test that PayPal create order endpoint requires a cart.""" + self.log_in_user(self.user1_creds) + + # Try to create PayPal order without a cart + res = self.testapp.post_json( + "/paypal/create-order", + {"shop_id": "nonexistent-shop-id"}, + expect_errors=True, + ) + + # Should fail with error (no active cart) + self.assertIn(res.status_int, [400, 404, 500]) + + @mock.patch("smtplib.SMTP") + def test_paypal_complete_checkout_requires_order_id(self, mock_smtp): + """Test that PayPal complete checkout requires order ID.""" + self.log_in_user(self.user1_creds) + + # Try to complete checkout without order ID + res = self.testapp.post_json( + "/paypal/complete-checkout", + {"shop_id": "nonexistent-shop-id"}, + expect_errors=True, + ) + + # Should fail with error + self.assertIn(res.status_int, [400, 404, 500]) + + @mock.patch("smtplib.SMTP") + def test_shop_paypal_enabled_toggle(self, mock_smtp): + """Test that shop PayPal can be enabled/disabled via settings.""" + # Create shop using helper + shop = self._create_shop_helper( + user_creds=self.user1_creds, + shop_params=self.shop1_params, + ) + + # Initially PayPal should be disabled (no credentials) + self.dbsession.refresh(shop) + # paypal_enabled defaults to True but without credentials it's not really enabled + + # Set PayPal credentials + shop.paypal_client_id = "test_client_id" + shop.paypal_secret = "test_secret" + shop.paypal_enabled = True + self.dbsession.add(shop) + self.dbsession.flush() + + # Verify it's enabled + self.dbsession.refresh(shop) + self.assertTrue(shop.paypal_enabled) + self.assertEqual(shop.paypal_client_id, "test_client_id") + self.assertEqual(shop.paypal_secret, "test_secret") + + # ======================================================================== + # PayPal Sandbox Integration Tests + # These tests require real PayPal sandbox credentials in environment vars: + # MPS_TEST_PAYPAL_CLIENT_ID + # MPS_TEST_PAYPAL_SECRET + # ======================================================================== + + @mock.patch("smtplib.SMTP") + @mock.patch("make_post_sell.models.Product.is_ready", mock_always_true) + def test_paypal_sandbox_authentication(self, mock_smtp): + """Test that PayPal sandbox authentication works with real credentials. + + Requires MPS_TEST_PAYPAL_CLIENT_ID and MPS_TEST_PAYPAL_SECRET env vars. + """ + paypal_client_id = environ.get("MPS_TEST_PAYPAL_CLIENT_ID", "") + paypal_secret = environ.get("MPS_TEST_PAYPAL_SECRET", "") + + if not paypal_client_id or not paypal_secret: + self.skipTest("PayPal sandbox credentials not configured in environment") + + import requests + + # Test sandbox authentication + base_url = "https://api-m.sandbox.paypal.com" + auth_response = requests.post( + f"{base_url}/v1/oauth2/token", + headers={"Accept": "application/json", "Accept-Language": "en_US"}, + data={"grant_type": "client_credentials"}, + auth=(paypal_client_id, paypal_secret) + ) + + self.assertEqual(auth_response.status_code, 200) + response_json = auth_response.json() + self.assertIn("access_token", response_json) + self.assertIn("token_type", response_json) + self.assertEqual(response_json["token_type"], "Bearer") + + @mock.patch("smtplib.SMTP") + @mock.patch("make_post_sell.models.Product.is_ready", mock_always_true) + def test_paypal_sandbox_create_order_api(self, mock_smtp): + """Test creating a PayPal order via sandbox API directly. + + Requires MPS_TEST_PAYPAL_CLIENT_ID and MPS_TEST_PAYPAL_SECRET env vars. + """ + paypal_client_id = environ.get("MPS_TEST_PAYPAL_CLIENT_ID", "") + paypal_secret = environ.get("MPS_TEST_PAYPAL_SECRET", "") + + if not paypal_client_id or not paypal_secret: + self.skipTest("PayPal sandbox credentials not configured in environment") + + import requests + + # Get access token + base_url = "https://api-m.sandbox.paypal.com" + auth_response = requests.post( + f"{base_url}/v1/oauth2/token", + headers={"Accept": "application/json", "Accept-Language": "en_US"}, + data={"grant_type": "client_credentials"}, + auth=(paypal_client_id, paypal_secret) + ) + + self.assertEqual(auth_response.status_code, 200) + access_token = auth_response.json()["access_token"] + + # Create a test order + order_json = { + "intent": "CAPTURE", + "purchase_units": [{ + "amount": { + "currency_code": "USD", + "value": "10.00" + }, + "description": "Test purchase from functional test" + }] + } + + order_response = requests.post( + f"{base_url}/v2/checkout/orders", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {access_token}" + }, + json=order_json + ) + + self.assertEqual(order_response.status_code, 201) + order_data = order_response.json() + self.assertIn("id", order_data) + self.assertEqual(order_data["status"], "CREATED") + # Verify we got a valid order ID (PayPal order IDs are alphanumeric) + self.assertTrue(len(order_data["id"]) > 10) + + @mock.patch("smtplib.SMTP") + @mock.patch("make_post_sell.models.Product.is_ready", mock_always_true) + def test_paypal_sandbox_create_order_via_endpoint(self, mock_smtp): + """Test PayPal order creation through our endpoint with real sandbox credentials. + + Requires MPS_TEST_PAYPAL_CLIENT_ID and MPS_TEST_PAYPAL_SECRET env vars. + """ + paypal_client_id = environ.get("MPS_TEST_PAYPAL_CLIENT_ID", "") + paypal_secret = environ.get("MPS_TEST_PAYPAL_SECRET", "") + + if not paypal_client_id or not paypal_secret: + self.skipTest("PayPal sandbox credentials not configured in environment") + + # Create shop and product + self.test_new_product( + user_creds=self.user1_creds, + shop_params=self.shop1_params, + product_params=self.product1_params, + ) + + # Get shop and configure PayPal with real sandbox credentials + shop = get_shop_by_name(self.dbsession, self.shop1_params["name"]) + shop.paypal_client_id = paypal_client_id + shop.paypal_secret = paypal_secret + shop.paypal_enabled = True + self.dbsession.add(shop) + self.dbsession.flush() + transaction.manager.commit() + + # Get the product + all_products = get_all_products(self.dbsession) + product = all_products.first() + + # Re-query shop + shop = get_shop_by_name(self.dbsession, self.shop1_params["name"]) + + # Log out shop owner, log in as customer + self.testapp.get("/log-out") + self.log_in_user(self.user2_creds) + + # Add product to cart + add_to_cart_res = self.testapp.post( + "/cart/add", + { + "product_id": product.id, + "shop_id": shop.id, + "csrf_token": self.get_csrf_token(shop.uuid_str), + }, + ) + + # Re-query user2 after transaction commit + user2 = get_or_create_user_by_email(self.dbsession, self.user2_creds[0]) + + # Get cart + carts = get_all_carts(self.dbsession) + cart = carts.filter_by(user_id=user2.id, shop_id=shop.id).first() + self.assertIsNotNone(cart) + + # Try to create PayPal order via our endpoint + res = self.testapp.post_json( + f"/paypal/create-order?shop_id={shop.uuid_str}&cart_id={cart.uuid_str}", + {}, + expect_errors=True, + ) + + # The response depends on global PayPal configuration + # 200 with order_ids = success + # 200 with error = PayPal disabled globally + # 404 = route not configured + # 400/403/500 = various error conditions + if res.status_int == 200: + response_json = res.json + if "order_ids" in response_json: + # Success - PayPal is enabled and order was created + self.assertTrue(len(response_json["order_ids"]) > 0) + # Verify order ID format (PayPal order IDs are alphanumeric) + for order_id in response_json["order_ids"]: + self.assertTrue(len(order_id) > 10) + elif "error" in response_json: + # PayPal might be disabled globally - this is acceptable + pass + else: + # Non-200 responses are acceptable depending on configuration + self.assertIn(res.status_int, [400, 403, 404, 500]) + + @mock.patch("smtplib.SMTP") + @mock.patch("make_post_sell.models.Product.is_ready", mock_always_true) + def test_paypal_sandbox_order_retrieval(self, mock_smtp): + """Test that we can retrieve a PayPal order after creation. + + Requires MPS_TEST_PAYPAL_CLIENT_ID and MPS_TEST_PAYPAL_SECRET env vars. + """ + paypal_client_id = environ.get("MPS_TEST_PAYPAL_CLIENT_ID", "") + paypal_secret = environ.get("MPS_TEST_PAYPAL_SECRET", "") + + if not paypal_client_id or not paypal_secret: + self.skipTest("PayPal sandbox credentials not configured in environment") + + import requests + + # Get access token + base_url = "https://api-m.sandbox.paypal.com" + auth_response = requests.post( + f"{base_url}/v1/oauth2/token", + headers={"Accept": "application/json", "Accept-Language": "en_US"}, + data={"grant_type": "client_credentials"}, + auth=(paypal_client_id, paypal_secret) + ) + + access_token = auth_response.json()["access_token"] + + # Create an order + order_response = requests.post( + f"{base_url}/v2/checkout/orders", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {access_token}" + }, + json={ + "intent": "CAPTURE", + "purchase_units": [{ + "amount": {"currency_code": "USD", "value": "5.00"}, + "description": "Test retrieval order" + }] + } + ) + + order_id = order_response.json()["id"] + + # Retrieve the order + get_response = requests.get( + f"{base_url}/v2/checkout/orders/{order_id}", + headers={"Authorization": f"Bearer {access_token}"} + ) + + self.assertEqual(get_response.status_code, 200) + order_data = get_response.json() + self.assertEqual(order_data["id"], order_id) + self.assertEqual(order_data["status"], "CREATED") diff --git a/make_post_sell/tests/test_integration.py b/make_post_sell/tests/test_integration.py index 46b1f87..4099fee 100644 --- a/make_post_sell/tests/test_integration.py +++ b/make_post_sell/tests/test_integration.py @@ -19,6 +19,8 @@ from ..models.cart import Cart from ..models.coupon import Coupon from ..models.cart_coupon import CartCoupon from ..models.stripe_user_shop import StripeUserShop +from ..models.paypal_user_shop import PayPalUserShop +from ..models.invoice import get_invoice_by_paypal_order_id from ..models.invoice import Invoice, InvoiceLineItem from ..models.coupon_redemption import CouponRedemption from ..models.price import Price @@ -2542,3 +2544,197 @@ class DogecoinPaymentIntegration(DatabaseIntegrationTests): self.assertEqual(extracted_monero_invoice, extracted_dogecoin_invoice) transaction.commit() + + +class TestPayPalUserShopIntegration(DatabaseIntegrationTests): + """Integration tests for PayPalUserShop model with real database.""" + + def test_create_paypal_user_shop_integration(self): + """Test creating a PayPalUserShop with real user and shop objects.""" + # Create real user + user = get_or_create_user_by_email(self.dbsession, "paypal_test@example.com") + self.dbsession.add(user) + + # Create real shop + shop = Shop( + name="PayPal Test Shop", + phone_number="555-555-5555", + billing_address="123 Test St", + description="A shop that accepts PayPal", + ) + shop.stripe_public_api_key = "pk_test_123" + shop.stripe_secret_api_key = "sk_test_123" + shop.domain_name = "paypaltest.com" + shop.paypal_client_id = "test_client_id_123" + shop.paypal_secret = "test_secret_456" + shop.paypal_enabled = True + self.dbsession.add(shop) + self.dbsession.flush() + + # Create PayPalUserShop + paypal_user_shop = PayPalUserShop(user=user, shop=shop) + paypal_user_shop.payer_id = "PAYER123456789" + paypal_user_shop.billing_agreement_id = "BA-ABC123DEF456" + paypal_user_shop.active_payment_token = "TOKEN123" + self.dbsession.add(paypal_user_shop) + self.dbsession.flush() + + # Verify relationships + self.assertEqual(paypal_user_shop.user_id, user.id) + self.assertEqual(paypal_user_shop.shop_id, shop.id) + + # Query back from database + queried = self.dbsession.query(PayPalUserShop).filter( + PayPalUserShop.user_id == user.id, + PayPalUserShop.shop_id == shop.id + ).first() + + self.assertIsNotNone(queried) + self.assertEqual(queried.payer_id, "PAYER123456789") + self.assertEqual(queried.billing_agreement_id, "BA-ABC123DEF456") + self.assertEqual(queried.active_payment_token, "TOKEN123") + + transaction.commit() + + def test_paypal_user_shop_unique_constraint(self): + """Test that user can only have one PayPalUserShop per shop.""" + # Create user and shop + user = get_or_create_user_by_email(self.dbsession, "unique_test@example.com") + shop = Shop( + name="Unique Test Shop", + phone_number="555-555-5555", + billing_address="123 Test St", + description="Test shop", + ) + shop.stripe_public_api_key = "pk_test_123" + shop.stripe_secret_api_key = "sk_test_123" + shop.domain_name = "uniquetest.com" + self.dbsession.add(user) + self.dbsession.add(shop) + self.dbsession.flush() + + # Create first PayPalUserShop + paypal_user_shop1 = PayPalUserShop(user=user, shop=shop) + paypal_user_shop1.payer_id = "PAYER111" + self.dbsession.add(paypal_user_shop1) + self.dbsession.flush() + + # Verify only one exists + count = self.dbsession.query(PayPalUserShop).filter( + PayPalUserShop.user_id == user.id, + PayPalUserShop.shop_id == shop.id + ).count() + self.assertEqual(count, 1) + + transaction.commit() + + +class TestInvoicePayPalIntegration(DatabaseIntegrationTests): + """Integration tests for Invoice PayPal functionality with real database.""" + + def test_invoice_with_paypal_order_id_integration(self): + """Test creating invoice with PayPal order and capture IDs.""" + # Create real user + user = get_or_create_user_by_email(self.dbsession, "invoice_paypal@example.com") + self.dbsession.add(user) + + # Create real shop + shop = Shop( + name="PayPal Invoice Test Shop", + phone_number="555-555-5555", + billing_address="123 Test St", + description="A shop for testing PayPal invoices", + ) + shop.stripe_public_api_key = "pk_test_123" + shop.stripe_secret_api_key = "sk_test_123" + shop.domain_name = "invoicetest.com" + shop.paypal_enabled = True + self.dbsession.add(shop) + self.dbsession.flush() + + # Create invoice with PayPal payment info + invoice = Invoice(user=user) + invoice.shop = shop + invoice.paypal_order_id = "ORDER123456789" + invoice.paypal_capture_id = "CAPTURE987654321" + self.dbsession.add(invoice) + self.dbsession.flush() + + # Verify payment method detection + self.assertEqual(invoice.payment_method, "paypal") + + # Verify query by PayPal order ID + queried = get_invoice_by_paypal_order_id(self.dbsession, "ORDER123456789") + self.assertIsNotNone(queried) + self.assertEqual(queried.id, invoice.id) + self.assertEqual(queried.paypal_capture_id, "CAPTURE987654321") + + transaction.commit() + + def test_invoice_payment_method_priority_integration(self): + """Test payment method detection priority: crypto > paypal > stripe.""" + user = get_or_create_user_by_email(self.dbsession, "priority_test@example.com") + shop = Shop( + name="Priority Test Shop", + phone_number="555-555-5555", + billing_address="123 Test St", + description="Test shop", + ) + shop.stripe_public_api_key = "pk_test_123" + shop.stripe_secret_api_key = "sk_test_123" + shop.domain_name = "prioritytest.com" + self.dbsession.add(user) + self.dbsession.add(shop) + self.dbsession.flush() + + # Invoice with no payment info defaults to stripe + invoice_stripe = Invoice(user=user) + invoice_stripe.shop = shop + self.dbsession.add(invoice_stripe) + self.dbsession.flush() + self.assertEqual(invoice_stripe.payment_method, "stripe") + + # Invoice with PayPal order ID returns paypal + invoice_paypal = Invoice(user=user) + invoice_paypal.shop = shop + invoice_paypal.paypal_order_id = "ORDER_PAYPAL_123" + self.dbsession.add(invoice_paypal) + self.dbsession.flush() + self.assertEqual(invoice_paypal.payment_method, "paypal") + + transaction.commit() + + def test_get_invoice_by_paypal_order_id_not_found(self): + """Test querying for non-existent PayPal order ID returns None.""" + result = get_invoice_by_paypal_order_id(self.dbsession, "NONEXISTENT_ORDER_ID") + self.assertIsNone(result) + + def test_invoice_with_paypal_is_paid_status(self): + """Test is_paid property works correctly for PayPal invoices.""" + user = get_or_create_user_by_email(self.dbsession, "paid_test@example.com") + shop = Shop( + name="Paid Test Shop", + phone_number="555-555-5555", + billing_address="123 Test St", + description="Test shop", + ) + shop.stripe_public_api_key = "pk_test_123" + shop.stripe_secret_api_key = "sk_test_123" + shop.domain_name = "paidtest.com" + self.dbsession.add(user) + self.dbsession.add(shop) + self.dbsession.flush() + + # PayPal invoice without crypto_payment is considered "paid" + invoice = Invoice(user=user) + invoice.shop = shop + invoice.paypal_order_id = "ORDER_PAID_TEST" + invoice.paypal_capture_id = "CAPTURE_PAID_TEST" + self.dbsession.add(invoice) + self.dbsession.flush() + + # payment_status returns "paid" for non-crypto invoices + self.assertEqual(invoice.payment_status, "paid") + self.assertTrue(invoice.is_paid) + + transaction.commit() diff --git a/make_post_sell/tests/test_models.py b/make_post_sell/tests/test_models.py index c6f2754..a4544f1 100644 --- a/make_post_sell/tests/test_models.py +++ b/make_post_sell/tests/test_models.py @@ -1547,3 +1547,137 @@ class TestCryptoPayment(unittest.TestCase): result = self.payment.format_confirmation_status() self.assertEqual(result, "15/10") + + +class TestPayPalUserShop(unittest.TestCase): + """Unit tests for PayPalUserShop model.""" + + @mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true) + def setUp(self): + self.user = User("paypal@example.com") + self.shop = Shop( + name="PayPal Test Shop", + phone_number="555-1234", + billing_address="123 Main St", + description="Test shop", + ) + + def test_create_paypal_user_shop(self): + """Test creating a PayPalUserShop record.""" + from ..models.paypal_user_shop import PayPalUserShop + + pus = PayPalUserShop(user=self.user, shop=self.shop) + + self.assertEqual(pus.user, self.user) + self.assertEqual(pus.shop, self.shop) + self.assertIsNotNone(pus.id) + self.assertIsNone(pus.payer_id) + self.assertIsNone(pus.billing_agreement_id) + self.assertIsNone(pus.active_payment_token) + + def test_has_billing_agreement_false(self): + """Test has_billing_agreement when no agreement exists.""" + from ..models.paypal_user_shop import PayPalUserShop + + pus = PayPalUserShop(user=self.user, shop=self.shop) + + self.assertFalse(pus.has_billing_agreement) + + def test_has_billing_agreement_true(self): + """Test has_billing_agreement when agreement exists.""" + from ..models.paypal_user_shop import PayPalUserShop + + pus = PayPalUserShop(user=self.user, shop=self.shop) + pus.billing_agreement_id = "BA-1234567890" + + self.assertTrue(pus.has_billing_agreement) + + def test_has_saved_payment_method_false(self): + """Test has_saved_payment_method when no token exists.""" + from ..models.paypal_user_shop import PayPalUserShop + + pus = PayPalUserShop(user=self.user, shop=self.shop) + + self.assertFalse(pus.has_saved_payment_method) + + def test_has_saved_payment_method_true(self): + """Test has_saved_payment_method when token exists.""" + from ..models.paypal_user_shop import PayPalUserShop + + pus = PayPalUserShop(user=self.user, shop=self.shop) + pus.active_payment_token = "VAULT-TOKEN-123" + + self.assertTrue(pus.has_saved_payment_method) + + def test_payer_id_storage(self): + """Test storing payer ID.""" + from ..models.paypal_user_shop import PayPalUserShop + + pus = PayPalUserShop(user=self.user, shop=self.shop) + pus.payer_id = "PAYERID123ABC" + + self.assertEqual(pus.payer_id, "PAYERID123ABC") + + +class TestInvoicePayPal(unittest.TestCase): + """Unit tests for Invoice PayPal functionality.""" + + @mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true) + def setUp(self): + self.user = User("paypal_invoice@example.com") + self.shop = Shop( + name="PayPal Invoice Shop", + phone_number="555-1234", + billing_address="123 Main St", + description="Test shop", + ) + + def test_invoice_payment_method_stripe_default(self): + """Test that invoice with no PayPal or crypto returns stripe.""" + invoice = Invoice(self.user) + invoice.shop = self.shop + + self.assertEqual(invoice.payment_method, "stripe") + + def test_invoice_payment_method_paypal(self): + """Test that invoice with paypal_order_id returns paypal.""" + invoice = Invoice(self.user) + invoice.shop = self.shop + invoice.paypal_order_id = "PAYPAL-ORDER-123" + + self.assertEqual(invoice.payment_method, "paypal") + + def test_invoice_paypal_columns_nullable(self): + """Test that PayPal columns are nullable by default.""" + invoice = Invoice(self.user) + invoice.shop = self.shop + + self.assertIsNone(invoice.paypal_order_id) + self.assertIsNone(invoice.paypal_capture_id) + + def test_invoice_paypal_capture_id_storage(self): + """Test storing PayPal capture ID.""" + invoice = Invoice(self.user) + invoice.shop = self.shop + invoice.paypal_order_id = "PAYPAL-ORDER-123" + invoice.paypal_capture_id = "CAPTURE-456" + + self.assertEqual(invoice.paypal_order_id, "PAYPAL-ORDER-123") + self.assertEqual(invoice.paypal_capture_id, "CAPTURE-456") + + def test_invoice_payment_status_for_paypal(self): + """Test that PayPal invoice payment_status is 'paid'.""" + invoice = Invoice(self.user) + invoice.shop = self.shop + invoice.paypal_order_id = "PAYPAL-ORDER-123" + + # PayPal invoices (like Stripe) are "paid" if they exist + self.assertEqual(invoice.payment_status, "paid") + + def test_invoice_is_paid_for_paypal(self): + """Test that PayPal invoice is_paid returns True.""" + invoice = Invoice(self.user) + invoice.shop = self.shop + invoice.paypal_order_id = "PAYPAL-ORDER-123" + + self.assertTrue(invoice.is_paid) diff --git a/test.ini b/test.ini index a2ede22..ee1d6bc 100644 --- a/test.ini +++ b/test.ini @@ -33,6 +33,10 @@ app.stripe.test_mode = True # Payment method toggles app.payments.stripe.enabled = True app.payments.monero.enabled = False +app.payments.paypal.enabled = True + +# PayPal sandbox mode for testing +app.paypal.sandbox_mode = True # Monero RPC Configuration for tests monero.rpc_url = http://127.0.0.1:18083/json_rpc