diff --git a/src/subscription/checkout/SubscriptionCheckout.test.jsx b/src/subscription/checkout/SubscriptionCheckout.test.jsx index ee3e2a7e9..51ad8419b 100644 --- a/src/subscription/checkout/SubscriptionCheckout.test.jsx +++ b/src/subscription/checkout/SubscriptionCheckout.test.jsx @@ -1,155 +1,97 @@ -/* eslint-disable react/jsx-no-constructed-context-values */ -/* eslint-disable global-require */ import React from 'react'; -import { mount } from 'enzyme'; -import { act } from 'react-dom/test-utils'; -import { createStore, applyMiddleware } from 'redux'; -import thunkMiddleware from 'redux-thunk'; -import { Provider } from 'react-redux'; import { Factory } from 'rosie'; -import { createSerializer } from 'enzyme-to-json'; -import { IntlProvider, configure as configureI18n } from '@edx/frontend-platform/i18n'; -import { getConfig } from '@edx/frontend-platform'; -import * as analytics from '@edx/frontend-platform/analytics'; -import Cookies from 'universal-cookie'; -import { Elements } from '@stripe/react-stripe-js'; -// import { loadStripe } from '@stripe/stripe-js'; import '../__factories__/subscription.factory'; -import '../../payment/__factories__/userAccount.factory'; -import { AppContext } from '@edx/frontend-platform/react'; +import { loadStripe } from '@stripe/stripe-js'; +import { + render, act, screen, store, +} from '../test-utils'; +import * as mocks from '../../payment/checkout/stripeMocks'; + import { SubscriptionCheckout } from './SubscriptionCheckout'; -import createRootReducer from '../../data/reducers'; import { fetchSubscriptionDetails, subscriptionDetailsReceived } from '../data/details/actions'; import { camelCaseObject } from '../../payment/data/utils'; -expect.addSnapshotSerializer(createSerializer({ mode: 'deep', noKey: true })); - -const config = getConfig(); -const locale = 'en'; - -configureI18n({ - config: { - ENVIRONMENT: process.env.ENVIRONMENT, - LANGUAGE_PREFERENCE_COOKIE_NAME: process.env.LANGUAGE_PREFERENCE_COOKIE_NAME, - }, - loggingService: { - logError: jest.fn(), - logInfo: jest.fn(), - }, - messages: { - uk: {}, - th: {}, - ru: {}, - 'pt-br': {}, - pl: {}, - 'ko-kr': {}, - id: {}, - he: {}, - ca: {}, - 'zh-cn': {}, - fr: {}, - 'es-419': {}, - ar: {}, - fa: {}, - 'fa-ir': {}, - }, -}); - -jest.mock('universal-cookie', () => { - class MockCookies { - static result = { - [process.env.LANGUAGE_PREFERENCE_COOKIE_NAME]: 'en', - [process.env.CURRENCY_COOKIE_NAME]: { - code: 'MXN', - rate: 19.092733, - }, - }; - - get(cookieName) { - return MockCookies.result[cookieName]; - } - } - return MockCookies; -}); - -jest.mock('@edx/frontend-platform/analytics', () => ({ - sendTrackEvent: jest.fn(), - sendPageEvent: jest.fn(), -})); - -// https://github.com/wwayne/react-tooltip/issues/595#issuecomment-638438372 -jest.mock('react-tooltip/node_modules/uuid', () => ({ - v4: () => '00000000-0000-0000-0000-000000000000', -})); - jest.mock('./StripeOptions', () => ({ - getStripeOptions: jest.fn().mockReturnValue({}), + getStripeOptions: jest.fn().mockReturnValue({ + mode: 'subscription', + amount: 55, + currency: 'usd', + paymentMethodCreation: 'manual', + }), })); -// jest.mock('@stripe/stripe-js', () => ({ -// loadStripe: jest.fn(), -// })); +jest.mock('@stripe/stripe-js', () => ({ + loadStripe: jest.fn(() => ({ + // mock implementation of the stripe object + })), +})); +/** + * SubscriptionCheckout Test + * https://github.com/stripe-archive/react-stripe-elements/issues/427 + * https://github.com/stripe/react-stripe-js/issues/59 + */ describe('', () => { - let store; - let tree; - + let subscriptionDetails; + let mockStripe; + let mockElements; + let mockElement; + let mockStripePromise; beforeEach(() => { - const authenticatedUser = Factory.build('userAccount'); - store = createStore(createRootReducer(), {}, applyMiddleware(thunkMiddleware)); - // eslint-disable-next-line no-import-assign - analytics.sendTrackingLogEvent = jest.fn(); - Cookies.result[process.env.CURRENCY_COOKIE_NAME] = undefined; - - // Mock the response of loadStripe method - // const mockedStripe = { - // elements: jest.fn(), - // }; - // loadStripe.mockResolvedValue(mockedStripe); - // TODO: make sure to test the form submit events - - const component = ( - - - - - - - - ); - - tree = mount(component); - }); - - afterEach(() => { - tree.unmount(); + // Arrange + mockStripe = mocks.mockStripe(); + mockElement = mocks.mockElement(); + mockElements = mocks.mockElements(); + mockStripe.elements.mockReturnValue(mockElements); + mockElements.create.mockReturnValue(mockElement); + mockStripePromise = jest.fn(() => Promise.resolve({ + ...mockStripe, + })); + subscriptionDetails = camelCaseObject(Factory.build('subscription', {}, { numProducts: 2 })); + loadStripe.mockResolvedValue(mockStripePromise); }); it('should render the loading skeleton for SubscriptionCheckout', () => { - tree.update(); + render(); + expect(screen.queryByText('Last Name (required)')).not.toBeInTheDocument(); // it doesn't exist + expect( + screen.queryByText('You’ll be charged $55.00 USD on April 21, 2025 then every 31 days until you cancel your subscription.'), + ).toBeNull(); + }); - expect(tree.find('CheckoutSkeleton')).toHaveLength(1); + it('should render the with the subscription details', () => { + const stripePromise = mockStripePromise(); - expect(tree).toMatchSnapshot(); - }); + loadStripe.mockResolvedValue(stripePromise); - it('should render the subscription checkout details', () => { + const { container } = render(); act(() => { store.dispatch( subscriptionDetailsReceived( - camelCaseObject(Factory.build('subscription', {}, { numProducts: 1 })), + subscriptionDetails, ), ); store.dispatch(fetchSubscriptionDetails.fulfill()); }); + expect(loadStripe).toHaveBeenCalledWith( + process.env.STRIPE_PUBLISHABLE_KEY, + { + betas: [process.env.STRIPE_DEFERRED_INTENT_BETA_FLAG], + apiVersion: process.env.STRIPE_API_VERSION, + locale: 'en', + }, + ); + expect(container).toMatchSnapshot(); + // screen.debug(container.querySelector('#payment-element')); - tree.update(); - expect(tree).toMatchSnapshot(); - - expect(tree.find('SubscriptionCheckout')).toHaveLength(1); + expect(container.querySelector('#payment-element')).toBeDefined(); - expect(tree.find(Elements)).toHaveLength(1); - expect(tree.find('StripePaymentForm')).toHaveLength(1); + // verify that Checkout Form fields are present in the DOM + expect(screen.queryByText('Last Name (required)')).toBeDefined(); + // verify that MonthlySubscriptionNotification is present in the DOM + expect( + screen.queryByText('You’ll be charged $55.00 USD on April 21, 2025 then every 31 days until you cancel your subscription.'), + ).toBeDefined(); }); }); diff --git a/src/subscription/checkout/__snapshots__/SubscriptionCheckout.test.jsx.snap b/src/subscription/checkout/__snapshots__/SubscriptionCheckout.test.jsx.snap index c83d2ec5b..48f3518d4 100644 --- a/src/subscription/checkout/__snapshots__/SubscriptionCheckout.test.jsx.snap +++ b/src/subscription/checkout/__snapshots__/SubscriptionCheckout.test.jsx.snap @@ -1,1778 +1,1472 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` should render the loading skeleton for SubscriptionCheckout 1`] = ` -
- Array [ - - - ‌ - -
-
, -
-
- - - ‌ - -
- - ‌ - -
- - ‌ - -
- - ‌ - -
-
-
-
- - - ‌ - -
- - ‌ - -
- - ‌ - -
- - ‌ - -
-
-
-
, - - - ‌ - -
-
, -
should render the with the subscription details 1`] = ` +
+
+
- - - ‌ - -
-
-
-
+ Card Holder Information +
- - - ‌ - -
-
+ First Name (required) + +
- - - ‌ - -
-
+ Last Name (required) + +
-
-
, -
-
- - - ‌ - -
-
-
-
, - - - ‌ - -
-
, - ] -
-`; - -exports[` should render the subscription checkout details 1`] = ` -
- -
-
- Card Holder Information -
-
-
- - -
-
- - -
-
-
-
- - -
- - -
-
-
-
- + +
+
- City (required) - - + + +
-
- +
+
+ +
+ +
-
-
- Array [ +
, + , - ] -
-
-
+
- Zip/Postal Code - - + + +
- -
- Billing Information (Required) -
-
-
-

- You will be charged $55 USD on April 21, 2025, then monthly until you cancel your subscription. -

-
-
+
+ Billing Information (Required) +
+
+
-   +
+   +
-
- -
+ + + `; diff --git a/src/subscription/data/service.js b/src/subscription/data/service.js index 6662619ab..fdceb9f1b 100644 --- a/src/subscription/data/service.js +++ b/src/subscription/data/service.js @@ -8,7 +8,7 @@ import { camelCaseObject } from '../../payment/data/utils'; * @param {data} to transform * @returns transformed data */ -const transformSubscriptionDetails = (data) => { +export const transformSubscriptionDetails = (data) => { const obj = camelCaseObject(data); obj.price = parseFloat(obj.price); obj.totalPrice = parseFloat(obj.totalPrice); diff --git a/src/subscription/data/service.test.js b/src/subscription/data/service.test.js new file mode 100644 index 000000000..06cfe2182 --- /dev/null +++ b/src/subscription/data/service.test.js @@ -0,0 +1,180 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { getConfig } from '@edx/frontend-platform'; + +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { + getDetails, + postDetails, + handleDetailsApiError, + transformSubscriptionDetails, +} from './service'; + +const axiosMock = new MockAdapter(axios); + +jest.mock('axios'); + +// Mock the getAuthenticatedHttpClient function +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedHttpClient: jest.fn(), +})); + +// Mock the getConfig function +jest.mock('@edx/frontend-platform', () => ({ + getConfig: jest.fn(() => ({ + SUBSCRIPTIONS_BASE_URL: process.env.SUBSCRIPTIONS_BASE_URL, + })), + ensureConfig: jest.fn(), +})); + +getAuthenticatedHttpClient.mockReturnValue(axios); + +beforeEach(() => { + axiosMock.reset(); +}); + +const errorJSON = { + code: 'invalid_card_details', + user_message: 'Invalid Card Details.', +}; + +describe('getDetails', () => { + const subscriptionDetails = { + price: '79.00', + total_price: '79.00', + program_title: 'Blockchain Fundamentals', + is_eligible_trial: true, + payment_processor: 'stripe', + currency: 'usd', + }; + test('should return transformed data on success', async () => { + axios.get.mockResolvedValue({ + data: subscriptionDetails, + }); + + const result = await getDetails(); + + expect(result).toEqual({ + price: 79, + totalPrice: 79, + programTitle: 'Blockchain Fundamentals', + isEligibleTrial: true, + paymentProcessor: 'stripe', + currency: 'usd', + }); + expect(axios.get).toHaveBeenCalledWith(`${getConfig().SUBSCRIPTIONS_BASE_URL}/api/v1/stripe-checkout/`); + }); + + test('should throw an error on API failure', async () => { + const error = new Error(); + error.errors = [errorJSON]; + axios.get.mockRejectedValue({ + data: { + response: errorJSON, + }, + }); + await expect(getDetails()).rejects.toEqual(error); + expect(axios.get).toHaveBeenCalledWith(`${getConfig().SUBSCRIPTIONS_BASE_URL}/api/v1/stripe-checkout/`); + }); +}); + +describe('postDetails', () => { + const payload = { + programTitle: 'Blockchain Fundamentals', + programUuid: 'program-uuid', + paymentMethodId: 'pm-payment-method', + billingDetails: { address: '123 street address' }, + }; + const subscriptionSuccessStatus = { + confirmation_status: 'succeeded', + subscription_id: 'subscription-id', + price: 79, + total_price: 79, + }; + + test('should return transformed data on success', async () => { + axios.post.mockResolvedValue({ + data: subscriptionSuccessStatus, + }); + + const result = await postDetails(payload); + + expect(result).toEqual({ + subscriptionId: 'subscription-id', + confirmationStatus: 'succeeded', + price: 79, + totalPrice: 79, + }); + expect(axios.post).toHaveBeenCalledWith(`${getConfig().SUBSCRIPTIONS_BASE_URL}/api/v1/stripe-checkout/`, payload); + }); + + test('should throw an error on API failure', async () => { + const error = new Error(); + error.errors = [errorJSON]; + axios.post.mockRejectedValue({ + data: { + response: errorJSON, + }, + }); + await expect(postDetails(payload)).rejects.toEqual(error); + expect(axios.post).toHaveBeenCalledWith(`${getConfig().SUBSCRIPTIONS_BASE_URL}/api/v1/stripe-checkout/`, payload); + }); +}); + +describe('handleDetailsApiError', () => { + test('should throw an error with API errors', () => { + const requestError = { + response: { + data: { + error_code: 'SOME_ERROR', + user_message: 'Some error occurred', + }, + }, + }; + + expect(() => { + handleDetailsApiError(requestError); + }).toThrow(Error); + + try { + handleDetailsApiError(requestError); + } catch (error) { + expect(error.errors).toEqual([ + { + code: 'SOME_ERROR', + userMessage: 'Some error occurred', + }, + ]); + } + }); + + test('should throw an error without API errors', () => { + const requestError = {}; + + expect(() => { + handleDetailsApiError(requestError); + }).toThrow(Error); + + try { + handleDetailsApiError(requestError); + } catch (error) { + expect(error.errors).toEqual([]); + } + }); +}); + +describe('transformSubscriptionDetails', () => { + test('should transform and parse price values', () => { + const data = { + price: '9.99', + total_price: '19.00', + }; + + const transformedData = transformSubscriptionDetails(data); + + expect(transformedData).toEqual({ + price: 9.99, + totalPrice: 19, + }); + }); +}); diff --git a/src/subscription/subscription-methods/stripe/service.test.js b/src/subscription/subscription-methods/stripe/service.test.js new file mode 100644 index 000000000..e34ab9a25 --- /dev/null +++ b/src/subscription/subscription-methods/stripe/service.test.js @@ -0,0 +1,91 @@ +import { logError } from '@edx/frontend-platform/logging'; +import { subscriptionStripeCheckout } from './service'; + +jest.mock('@edx/frontend-platform/logging', () => ({ + logError: jest.fn(), +})); + +describe('subscriptionStripeCheckout', () => { + const programUuid = '123'; + const programTitle = 'Sample Program'; + const elements = { /* mock elements */ }; + const mockedBillingDetails = { + address: '123 Main St', + unit: 'Apt 4', + city: 'City', + country: 'Country', + state: 'State', + postalCode: '12345', + }; + const context = { authenticatedUser: { email: 'test@example.com' } }; + const values = { + firstName: 'John', + lastName: 'Doe', + ...mockedBillingDetails, + }; + + it('should return the expected post data', async () => { + // Arrange + const stripe = { + createPaymentMethod: jest.fn().mockResolvedValue({ + paymentMethod: { + id: 'payment-method-id', + billing_details: mockedBillingDetails, + }, + error: null, + }), + }; + + // Act + const postData = await subscriptionStripeCheckout({ programUuid, programTitle }, { + elements, stripe, context, values, + }); + + // Assert + expect(stripe.createPaymentMethod).toHaveBeenCalledWith({ + elements, + params: { + billing_details: { + address: { + city: values.city, + country: values.country, + line1: values.address, + line2: values.unit || '', + postal_code: values.postalCode || '', + state: values.state || '', + }, + email: context.authenticatedUser.email, + name: `${values.firstName} ${values.lastName}`, + }, + }, + }); + + expect(postData).toEqual({ + program_uuid: programUuid, + program_title: programTitle, + payment_method_id: 'payment-method-id', + billing_details: { + ...mockedBillingDetails, + firstname: values.firstName, + lastname: values.lastName, + }, + }); + expect(logError).not.toHaveBeenCalled(); + }); + + it('should throw an error and log it', async () => { + // Arrange + const stripe = { createPaymentMethod: jest.fn().mockResolvedValue({ paymentMethod: null, error: new Error('Stripe Error') }) }; + + // Act & Assert + await expect(subscriptionStripeCheckout({ programUuid, programTitle }, { + elements, stripe, context, values, + })).rejects.toThrow(Error); + expect(logError).toHaveBeenCalledWith(new Error('Stripe Error'), { + messagePrefix: 'Stripe Submit Error', + paymentMethod: 'createPaymentMethod', + paymentErrorType: 'createPaymentMethod Error', + programUuid, + }); + }); +});