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,