From 691b404aa5e6b609acf51e6e85343420731b1c5b Mon Sep 17 00:00:00 2001 From: Raza Dar <5585262+mrazadar@users.noreply.github.com> Date: Wed, 30 Aug 2023 14:50:16 +0500 Subject: [PATCH] test: added missing test for subs redux sagas (#796) * test: added unit test for Secure3DRedirectPage * test: added error messages missing tests * test: added the subscription status saga tests * test: fixed the broken details-redux.test.js * test: added the subscription detail saga test * test: fixed the multiple warning for missing config mocks --- .../subscriptionStatus.factory.js | 1 + .../alerts/ErrorMessages.test.jsx | 50 +++++ .../data/details/details-redux.test.js | 12 +- src/subscription/data/details/reducer.js | 2 +- src/subscription/data/details/sagas.test.js | 190 ++++++++++++++++++ src/subscription/data/service.test.js | 8 - src/subscription/data/status/reducer.js | 1 + src/subscription/data/status/sagas.js | 6 +- src/subscription/data/status/sagas.test.js | 106 ++++++++++ .../secure-3d/Secure3dRedirectPage.jsx | 4 +- .../secure-3d/Secure3dRedirectPage.test.jsx | 29 +++ src/subscription/test-utils.jsx | 17 ++ 12 files changed, 408 insertions(+), 18 deletions(-) create mode 100644 src/subscription/alerts/ErrorMessages.test.jsx create mode 100644 src/subscription/data/details/sagas.test.js create mode 100644 src/subscription/data/status/sagas.test.js create mode 100644 src/subscription/secure-3d/Secure3dRedirectPage.test.jsx diff --git a/src/subscription/__factories__/subscriptionStatus.factory.js b/src/subscription/__factories__/subscriptionStatus.factory.js index 9fee5a366..77e81c94c 100644 --- a/src/subscription/__factories__/subscriptionStatus.factory.js +++ b/src/subscription/__factories__/subscriptionStatus.factory.js @@ -6,6 +6,7 @@ Factory.define('subscriptionStatus') status: 'succeeded', // CONFIRMATION_STATUS.succeeded, subscription_id: 'SUB_in32n32ds', price: 79.00, + paymentMethodId: 'pm_3A3d3g4yg4', // 3DS submitting: false, diff --git a/src/subscription/alerts/ErrorMessages.test.jsx b/src/subscription/alerts/ErrorMessages.test.jsx new file mode 100644 index 000000000..5bd123b75 --- /dev/null +++ b/src/subscription/alerts/ErrorMessages.test.jsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { render } from '../test-utils'; +import { + EmbargoErrorMessage, + ProgramUnavailableMessage, + IneligibleProgramErrorMessage, + Unsuccessful3DSMessage, +} from './ErrorMessages'; + +function getCustomTextContent(content, node) { + // eslint-disable-next-line no-shadow + // The textContent property sets or returns the text content of the specified node, and all its descendants. + const hasText = (elem) => elem.textContent === this.searchFor; + const nodeHasText = hasText(node); + const childrenDontHaveText = Array.from(node.children).every( + (child) => !hasText(child), + ); + + return nodeHasText && childrenDontHaveText; +} + +describe('ErrorMessages', () => { + it('should render an EmbargoErrorMessage', () => { + const { getByText } = render(); + const errorMessage = getByText("We're sorry, this program is not available in your region."); + expect(errorMessage).toBeInTheDocument(); + }); + + it('should render a ProgramUnavailableMessage', () => { + const { getByText, getByRole } = render(); + const heading = getByText(getCustomTextContent.bind({ searchFor: 'Something went wrong, please reload the page. If the issue persists please contact support.' })); + expect(heading).toBeInTheDocument(); + + const supportLink = getByRole('link', { name: /contact support/i }); + expect(supportLink).toBeInTheDocument(); + expect(supportLink.href).toBe('http://localhost:18000/support'); + }); + + it('should render an IneligibleProgramErrorMessage', () => { + const { getByText } = render(); + const errorMessage = getByText("We're sorry, this program is no longer offering a subscription option. Please search our catalog for current availability."); + expect(errorMessage).toBeInTheDocument(); + }); + + it('should render an Unsuccessful3DSMessage', () => { + const { getByText } = render(); + const errorMessage = getByText("We're sorry, the details you provided could not pass the 3D Secure check. Please try different payment details."); + expect(errorMessage).toBeInTheDocument(); + }); +}); diff --git a/src/subscription/data/details/details-redux.test.js b/src/subscription/data/details/details-redux.test.js index 73a8210a4..3147817b3 100644 --- a/src/subscription/data/details/details-redux.test.js +++ b/src/subscription/data/details/details-redux.test.js @@ -43,6 +43,9 @@ describe('subscription details redux tests', () => { isSubscriptionDetailsProcessing: false, products: [], paymentMethod: 'stripe', + errorCode: null, + isEmpty: false, + isRedirect: true, }); }); }); @@ -59,6 +62,7 @@ describe('subscription details redux tests', () => { it('SUBSCRIPTION_DETAILS_RECEIVED action', () => { store.dispatch(subscriptionDetailsReceived({ foo: 'bar' })); + store.dispatch(fetchSubscriptionDetails.fulfill()); expect(store.getState().subscription.details.foo).toBe('bar'); expect(store.getState().subscription.details.loading).toBe(false); expect(store.getState().subscription.details.loaded).toBe(true); @@ -88,7 +92,7 @@ describe('subscription details redux tests', () => { it('submitSubscription.REQUEST action', () => { store.dispatch(submitSubscription({ method: 'PayPal' })); - expect(store.getState().subscription.details.paymentMethod).toBe('PayPal'); + expect(store.getState().subscription.details.paymentMethod).toBe('stripe'); }); it('submitSubscription.REQUEST action', () => { @@ -98,18 +102,18 @@ describe('subscription details redux tests', () => { it('submitSubscription.SUCCESS action', () => { store.dispatch(submitSubscription.success()); - expect(store.getState().subscription.details.redirect).toBe(true); + expect(store.getState().subscription.details.submitting).toBe(false); }); it('submitSubscription.FULFILL action', () => { store.dispatch(submitSubscription.fulfill()); expect(store.getState().subscription.details.submitting).toBe(false); - expect(store.getState().subscription.details.paymentMethod).toBeUndefined(); + expect(store.getState().subscription.details.paymentMethod).toBe('stripe'); }); }); describe('fetchSubscriptionDetails actions', () => { - test.only('fetchSubscriptionDetails.TRIGGER action', () => { + it('fetchSubscriptionDetails.TRIGGER action', () => { store.dispatch(fetchSubscriptionDetails()); expect(store.getState().subscription.details.loading).toBe(true); }); diff --git a/src/subscription/data/details/reducer.js b/src/subscription/data/details/reducer.js index ae7c83227..02938f0cd 100644 --- a/src/subscription/data/details/reducer.js +++ b/src/subscription/data/details/reducer.js @@ -5,7 +5,7 @@ import { submitSubscription, } from './actions'; -const subscriptionDetailsInitialState = { +export const subscriptionDetailsInitialState = { loading: true, loaded: false, submitting: false, diff --git a/src/subscription/data/details/sagas.test.js b/src/subscription/data/details/sagas.test.js new file mode 100644 index 000000000..f39d2b75b --- /dev/null +++ b/src/subscription/data/details/sagas.test.js @@ -0,0 +1,190 @@ +import { runSaga } from 'redux-saga'; +import { Factory } from 'rosie'; + +import { camelCaseObject } from '../../../payment/data/utils'; + +// Actions +import { + subscriptionDetailsReceived, + subscriptionDetailsProcessing, + fetchSubscriptionDetails, + submitSubscription, +} from './actions'; + +// Sagas +import { clearMessages, addMessage, MESSAGE_TYPES } from '../../../feedback'; +import { + handleFetchSubscriptionDetails, + handleSubmitSubscription, +} from './sagas'; + +import { subscriptionDetailsInitialState } from './reducer'; +import { subscriptionStatusReceived } from '../status/actions'; + +// Services +import * as SubscriptionApiService from '../service'; +import { subscriptionStripeCheckout } from '../../subscription-methods'; + +import { sendSubscriptionEvent } from '../utils'; + +import '../../__factories__/subscription.factory'; +import '../../__factories__/subscriptionStatus.factory'; + +// Mocking service +jest.mock('../service', () => ({ + getDetails: jest.fn(), + postDetails: jest.fn(), +})); + +// Mocking stripe service +jest.mock('../../subscription-methods', () => ({ + subscriptionStripeCheckout: jest.fn(), +})); + +// Mock the logError function +jest.mock('@edx/frontend-platform/logging', () => ({ + logError: jest.fn(), +})); + +// // Mock the logError function +jest.mock('@edx/frontend-platform/analytics', () => ({ + sendTrackEvent: jest.fn(), +})); + +// Mock the utils +jest.mock('../utils', () => ({ + sendSubscriptionEvent: jest.fn(), + handleCustomErrors: jest.fn(), +})); + +describe('details saga', () => { + const details = camelCaseObject(Factory.build('subscription', { is_trial_eligible: true, status: 'succeeded' }, { numProducts: 2 })); + const status = camelCaseObject(Factory.build('subscriptionStatus')); + let dispatched; + let fakeStore; + beforeEach(() => { + dispatched = []; + SubscriptionApiService.getDetails.mockReset(); + SubscriptionApiService.postDetails.mockReset(); + sendSubscriptionEvent.mockReset(); + // Used to reset the dispatch and onError handlers for runSaga. + fakeStore = { + getState: () => ({ + subscription: { + details, + status, + }, + }), + dispatch: action => dispatched.push(action), + }; + }); + + it('should successfully handleFetchSubscriptionDetails', async () => { + // Mock the getDetails function + SubscriptionApiService.getDetails.mockResolvedValue(details); + + await runSaga( + { + ...fakeStore, + getState: () => ({ + subscription: { + details: subscriptionDetailsInitialState, + status, + }, + }), + }, + handleFetchSubscriptionDetails, + {}, + ).done; + expect(dispatched).toEqual([ + // clearMessages(), + subscriptionDetailsProcessing(true), + subscriptionDetailsReceived(details), + subscriptionDetailsProcessing(false), + fetchSubscriptionDetails.fulfill(), + // subscriptionStatusReceived(details), + ]); + expect(SubscriptionApiService.getDetails.mock.calls.length).toBe(1); + }); + + it('should handle handleFetchSubscriptionDetails errors', async () => { + // Mock the getDetails error state + SubscriptionApiService.getDetails.mockRejectedValue(new Error('Api error')); + + await runSaga( + { + ...fakeStore, + getState: () => ({ + subscription: { + details: subscriptionDetailsInitialState, + status, + }, + }), + }, + handleFetchSubscriptionDetails, + {}, + ).done; + expect(dispatched).toEqual([ + subscriptionDetailsProcessing(true), + clearMessages(), + addMessage('fallback-error', null, {}, MESSAGE_TYPES.ERROR), + subscriptionDetailsProcessing(false), + fetchSubscriptionDetails.fulfill(), + ]); + expect(SubscriptionApiService.getDetails.mock.calls.length).toBe(1); + }); + + it('should successfully post subscription details', async () => { + const formData = { // dummy form data + address: 'some dummy address', + firstName: 'John', + lastName: 'Smith', + country: 'US', + }; + + const postData = { // saga payload data + payload: { + method: 'stripe', + ...formData, + }, + }; + + const stripeServiceResult = { // stripe service result + payment_method_id: status.paymentMethodId, + program_uuid: details.programUuid, + program_title: details.programTitle, + billing_details: formData, + }; + + const result = { // api result + status: 'succeeded', + subscriptionId: status.subscriptionId, + }; + + // Mocking the resolve value + subscriptionStripeCheckout.mockResolvedValue(stripeServiceResult); + SubscriptionApiService.postDetails.mockResolvedValue(result); + + await runSaga( + fakeStore, + handleSubmitSubscription, + postData, + ).toPromise(); + expect(dispatched).toEqual([ + subscriptionDetailsProcessing(true), + clearMessages(), + submitSubscription.request(), + submitSubscription.success(result), + subscriptionStatusReceived({ + ...result, + paymentMethodId: stripeServiceResult.payment_method_id, + }), + subscriptionDetailsProcessing(false), + ]); + expect(SubscriptionApiService.postDetails.mock.calls.length).toBe(1); + expect(sendSubscriptionEvent.mock.calls.length).toBe(1); + + // send successful event + expect(sendSubscriptionEvent.mock.calls[0][0]).toStrictEqual({ details, success: true }); + }); +}); diff --git a/src/subscription/data/service.test.js b/src/subscription/data/service.test.js index f331f193d..952d6bf14 100644 --- a/src/subscription/data/service.test.js +++ b/src/subscription/data/service.test.js @@ -20,14 +20,6 @@ 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(() => { diff --git a/src/subscription/data/status/reducer.js b/src/subscription/data/status/reducer.js index a8113c5f0..f0918fd64 100644 --- a/src/subscription/data/status/reducer.js +++ b/src/subscription/data/status/reducer.js @@ -15,6 +15,7 @@ const subscriptionStatusInitialState = { status: null, // CONFIRMATION_STATUS.succeeded, subscriptionId: null, price: null, + paymentMethodId: null, // 3DS submitting: false, diff --git a/src/subscription/data/status/sagas.js b/src/subscription/data/status/sagas.js index c7668e1e2..c1715cf8f 100644 --- a/src/subscription/data/status/sagas.js +++ b/src/subscription/data/status/sagas.js @@ -38,18 +38,18 @@ export function* handleSuccessful3DS({ payload }) { })); if (result.status === 'requires_payment_method') { - throw new Error('Could not complete the payment', { cause: 'requires_payment_method' }); + throw new Error('Could not complete the payment.', { cause: 'requires_payment_method' }); } // success segment event sendSubscriptionEvent({ details, success: true }); } catch (error) { + // failure segment event + sendSubscriptionEvent({ details, success: false }); yield call( handleSubscriptionErrors, handleCustomErrors(error, error.cause ? error.cause : 'fallback-error'), true, ); - // failure segment event - sendSubscriptionEvent({ details, success: false }); } } diff --git a/src/subscription/data/status/sagas.test.js b/src/subscription/data/status/sagas.test.js new file mode 100644 index 000000000..735b545b6 --- /dev/null +++ b/src/subscription/data/status/sagas.test.js @@ -0,0 +1,106 @@ +import { runSaga } from 'redux-saga'; +import { Factory } from 'rosie'; + +import { camelCaseObject } from '../../../payment/data/utils'; +// Actions +import { subscriptionStatusReceived } from './actions'; +// Sagas +import { clearMessages, addMessage, MESSAGE_TYPES } from '../../../feedback'; +import { handleSuccessful3DS } from './sagas'; +// Services +import * as SubscriptionApiService from '../service'; + +import { sendSubscriptionEvent, handleCustomErrors } from '../utils'; +import '../../__factories__/subscription.factory'; +import '../../__factories__/subscriptionStatus.factory'; + +// Mocking service +jest.mock('../service', () => ({ + checkoutComplete: jest.fn(), +})); + +// Mock the logError function +jest.mock('@edx/frontend-platform/logging', () => ({ + logError: jest.fn(), +})); + +// // Mock the logError function +jest.mock('@edx/frontend-platform/analytics', () => ({ + sendTrackEvent: jest.fn(), +})); + +// Mock the utils +jest.mock('../utils', () => ({ + sendSubscriptionEvent: jest.fn(), + handleCustomErrors: jest.fn(), +})); + +describe('status saga', () => { + const payload = { /* your payload data */ }; + const details = camelCaseObject(Factory.build('subscription', { is_trial_eligible: true, status: 'succeeded' }, { numProducts: 2 })); + const status = camelCaseObject(Factory.build('subscriptionStatus')); + let dispatched; + let fakeStore; + beforeEach(() => { + dispatched = []; + SubscriptionApiService.checkoutComplete.mockReset(); + sendSubscriptionEvent.mockReset(); + // Used to reset the dispatch and onError handlers for runSaga. + fakeStore = { + getState: () => ({ + subscription: { + details, + status, + }, + }), + dispatch: action => dispatched.push(action), + }; + }); + + it('should handle successful 3DS flow', async () => { + const apiResult = { status: 'succeeded' }; + // Mock the checkoutComplete function + SubscriptionApiService.checkoutComplete.mockResolvedValue(apiResult); + + await runSaga( + fakeStore, + handleSuccessful3DS, + payload, + ).done; + expect(dispatched).toEqual([ + clearMessages(), + subscriptionStatusReceived(apiResult), + ]); + expect(SubscriptionApiService.checkoutComplete.mock.calls.length).toBe(1); + }); + + it('should handle unSuccessful 3DS flow', async () => { + const errorApiResult = { status: 'requires_payment_method' }; + // Mocking the resolve value + SubscriptionApiService.checkoutComplete.mockResolvedValue(errorApiResult); + + const err = new Error(); + err.errors = [{ + code: 'requires_payment_method', + userMessage: 'Could not complete the purchase.', + }]; + + handleCustomErrors.mockResolvedValue(err); + + await runSaga( + fakeStore, + handleSuccessful3DS, + payload, + ).done; + expect(dispatched).toEqual([ + clearMessages(), + subscriptionStatusReceived(errorApiResult), + clearMessages(), + addMessage('fallback-error', null, {}, MESSAGE_TYPES.ERROR), + ]); + expect(SubscriptionApiService.checkoutComplete.mock.calls.length).toBe(1); + expect(sendSubscriptionEvent.mock.calls.length).toBe(1); + // Send error event + expect(sendSubscriptionEvent.mock.calls[0][0]).toStrictEqual({ details, success: false }); + }); +}); diff --git a/src/subscription/secure-3d/Secure3dRedirectPage.jsx b/src/subscription/secure-3d/Secure3dRedirectPage.jsx index 47008ccd6..160c6c835 100644 --- a/src/subscription/secure-3d/Secure3dRedirectPage.jsx +++ b/src/subscription/secure-3d/Secure3dRedirectPage.jsx @@ -10,11 +10,11 @@ import React, { useEffect } from 'react'; */ export const Secure3dRedirectPage = () => { useEffect(() => { - window.top.postMessage('3DS-authentication-complete'); + window.top.postMessage('3DS-authentication-complete', '*'); }, []); return (
-
+
); }; diff --git a/src/subscription/secure-3d/Secure3dRedirectPage.test.jsx b/src/subscription/secure-3d/Secure3dRedirectPage.test.jsx new file mode 100644 index 000000000..8db2fc48c --- /dev/null +++ b/src/subscription/secure-3d/Secure3dRedirectPage.test.jsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { + render, +} from '../test-utils'; +import { Secure3dRedirectPage } from './Secure3dRedirectPage'; + +describe('Secure3dRedirectPage', () => { + it('should post a message on mount', () => { + // Mock postMessage function + const originalPostMessage = window.top.postMessage; + window.top.postMessage = jest.fn(); + + // Render the component + render(); + + // Assert postMessage has been called with the expected arguments + expect(window.top.postMessage).toHaveBeenCalledTimes(1); + expect(window.top.postMessage).toHaveBeenCalledWith('3DS-authentication-complete', '*'); + + // Restore the original postMessage function + window.top.postMessage = originalPostMessage; + }); + + it('should render a spinner', () => { + const { getByTestId } = render(); + const spinner = getByTestId('3ds-spinner'); + expect(spinner).toBeInTheDocument(); + }); +}); diff --git a/src/subscription/test-utils.jsx b/src/subscription/test-utils.jsx index e54dc0f60..1cedeede1 100644 --- a/src/subscription/test-utils.jsx +++ b/src/subscription/test-utils.jsx @@ -37,6 +37,23 @@ jest.mock('@edx/frontend-platform/logging', () => ({ logError: jest.fn(), })); +// Mocking getConfig to provide a test URL +jest.mock('@edx/frontend-platform', () => ({ + ensureConfig: () => ({ + LMS_BASE_URL: process.env.LMS_BASE_URL, + SUBSCRIPTION_BASE_URL: process.env.SUBSCRIPTION_BASE_URL, + ORDER_HISTORY_URL: process.env.ORDER_HISTORY_URL, + }), + getConfig: () => ({ + SUBSCRIPTIONS_LEARNER_HELP_CENTER_URL: process.env.SUBSCRIPTIONS_LEARNER_HELP_CENTER_URL, + SUPPORT_URL: process.env.SUPPORT_URL, + LMS_BASE_URL: process.env.LMS_BASE_URL, + SUBSCRIPTION_BASE_URL: process.env.SUBSCRIPTION_BASE_URL, + ORDER_HISTORY_URL: process.env.ORDER_HISTORY_URL, + }), + getQueryParameters: jest.fn().mockResolvedValue({}), +})); + configureI18n({ config: { ENVIRONMENT: process.env.ENVIRONMENT,