@akinon/pz-masterpass-rest 1.126.10 → 1.126.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { renderHook, act } from '@testing-library/react';
|
|
3
|
+
import { Provider } from 'react-redux';
|
|
4
|
+
import { configureStore } from '@reduxjs/toolkit';
|
|
5
|
+
|
|
6
|
+
import masterpassRestReducer from '../redux/reducer';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* ENT-114: MasterpassRestOption mounts two token hooks (payment and account)
|
|
10
|
+
* on the same query. Refreshing the token for a payment used to make the
|
|
11
|
+
* account hook push its older query result back into the store and run its
|
|
12
|
+
* onTokenReady again, which reloads the Masterpass account while the payment
|
|
13
|
+
* is being opened.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const FIRST = { token: 'token-1', extras: { terminal_group_id: 'group', bank_ica: null } };
|
|
17
|
+
const REFRESHED = { token: 'token-2', extras: { terminal_group_id: 'group', bank_ica: null } };
|
|
18
|
+
|
|
19
|
+
// Both hook instances read the same (initial) query result, as the shared
|
|
20
|
+
// RTK Query subscription does.
|
|
21
|
+
let queryData: typeof FIRST | undefined = FIRST;
|
|
22
|
+
const fetchTokenMock = jest.fn(async () => ({ data: REFRESHED }));
|
|
23
|
+
|
|
24
|
+
jest.mock('../redux/api', () => ({
|
|
25
|
+
useGetMasterpassRestTokenQuery: () => ({
|
|
26
|
+
data: queryData,
|
|
27
|
+
refetch: jest.fn(),
|
|
28
|
+
isLoading: false,
|
|
29
|
+
error: undefined
|
|
30
|
+
}),
|
|
31
|
+
useLazyGetMasterpassRestTokenQuery: () => [fetchTokenMock]
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
// Imported after the mocks so the hook picks them up.
|
|
35
|
+
import { useMasterpassToken } from './useMasterpassToken';
|
|
36
|
+
|
|
37
|
+
const settle = async () => {
|
|
38
|
+
for (let i = 0; i < 5; i += 1) {
|
|
39
|
+
await act(async () => {
|
|
40
|
+
await Promise.resolve();
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const renderTwoInstances = () => {
|
|
46
|
+
const store = configureStore({
|
|
47
|
+
reducer: { masterpassRest: masterpassRestReducer },
|
|
48
|
+
middleware: (getDefault) => getDefault({ serializableCheck: false })
|
|
49
|
+
});
|
|
50
|
+
const accountReady = jest.fn();
|
|
51
|
+
|
|
52
|
+
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
53
|
+
<Provider store={store}>{children}</Provider>
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const hook = renderHook(
|
|
57
|
+
() => ({
|
|
58
|
+
payment: useMasterpassToken({ useThreeD: true }),
|
|
59
|
+
account: useMasterpassToken({ useThreeD: true, onTokenReady: accountReady })
|
|
60
|
+
}),
|
|
61
|
+
{ wrapper }
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
return { ...hook, store, accountReady };
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
queryData = FIRST;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('token shared by several hook instances', () => {
|
|
72
|
+
it('syncs the first query result into the store once', async () => {
|
|
73
|
+
const { store, accountReady } = renderTwoInstances();
|
|
74
|
+
await settle();
|
|
75
|
+
|
|
76
|
+
expect(store.getState().masterpassRest.token).toBe('token-1');
|
|
77
|
+
expect(accountReady).toHaveBeenCalledTimes(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('keeps the refreshed token after a payment refresh', async () => {
|
|
81
|
+
const { result, store, accountReady } = renderTwoInstances();
|
|
82
|
+
await settle();
|
|
83
|
+
|
|
84
|
+
await act(async () => {
|
|
85
|
+
await result.current.payment.refetch({ skipReset: true });
|
|
86
|
+
});
|
|
87
|
+
await settle();
|
|
88
|
+
|
|
89
|
+
expect(fetchTokenMock).toHaveBeenCalledTimes(1);
|
|
90
|
+
expect(store.getState().masterpassRest.token).toBe('token-2');
|
|
91
|
+
// No second account initialization while the payment is being opened.
|
|
92
|
+
expect(accountReady).toHaveBeenCalledTimes(1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('still syncs a new query result', async () => {
|
|
96
|
+
const { rerender, store, accountReady } = renderTwoInstances();
|
|
97
|
+
await settle();
|
|
98
|
+
|
|
99
|
+
queryData = { token: 'token-3', extras: { terminal_group_id: 'group', bank_ica: null } };
|
|
100
|
+
rerender();
|
|
101
|
+
await settle();
|
|
102
|
+
|
|
103
|
+
expect(store.getState().masterpassRest.token).toBe('token-3');
|
|
104
|
+
expect(accountReady).toHaveBeenCalledTimes(2);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -32,6 +32,10 @@ export const useMasterpassToken = ({
|
|
|
32
32
|
}: UseMasterpassTokenProps) => {
|
|
33
33
|
const dispatch = useDispatch();
|
|
34
34
|
const skipTokenSyncRef = useRef(false);
|
|
35
|
+
// Several instances share the token query. When one of them refreshes the
|
|
36
|
+
// token, the others still hold the previous query result; they must not push
|
|
37
|
+
// it back into the store or re-run their onTokenReady (ENT-114).
|
|
38
|
+
const lastQueryTokenRef = useRef<string | null>(null);
|
|
35
39
|
const onTokenReadyRef = useRef(onTokenReady);
|
|
36
40
|
onTokenReadyRef.current = onTokenReady;
|
|
37
41
|
|
|
@@ -69,7 +73,12 @@ export const useMasterpassToken = ({
|
|
|
69
73
|
return;
|
|
70
74
|
}
|
|
71
75
|
|
|
72
|
-
if (!
|
|
76
|
+
if (!data?.token || data.token === lastQueryTokenRef.current) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (data.token === reduxToken) {
|
|
81
|
+
lastQueryTokenRef.current = data.token;
|
|
73
82
|
return;
|
|
74
83
|
}
|
|
75
84
|
|
|
@@ -91,6 +100,7 @@ export const useMasterpassToken = ({
|
|
|
91
100
|
return;
|
|
92
101
|
}
|
|
93
102
|
|
|
103
|
+
lastQueryTokenRef.current = token;
|
|
94
104
|
dispatch(setMasterpassRestToken(token));
|
|
95
105
|
dispatch(setMasterpassTerminalGroupId(extras.terminal_group_id));
|
|
96
106
|
dispatch(setMasterpassBankIca(extras.bank_ica));
|
|
@@ -227,6 +227,11 @@ const MasterpassRestOption: React.FC<MasterpassRestOptionProps> = ({
|
|
|
227
227
|
const [cvc, setCvc] = useState<string>('');
|
|
228
228
|
const [isProcessingPayment, setIsProcessingPayment] = useState(false);
|
|
229
229
|
|
|
230
|
+
// Set while a payment request is on its way. Card, installment and bin pages
|
|
231
|
+
// sent next to MasterpassRestOrderNoPage race with it on the backend and the
|
|
232
|
+
// completion then carries a different order number (ENT-114).
|
|
233
|
+
const paymentInFlightRef = useRef(false);
|
|
234
|
+
|
|
230
235
|
// Initialized during render (not in an effect) so consumers see the real
|
|
231
236
|
// value before their own mount effects run.
|
|
232
237
|
const [isPaymentPending, setIsPaymentPending] = useState<boolean>(() =>
|
|
@@ -297,21 +302,24 @@ const MasterpassRestOption: React.FC<MasterpassRestOptionProps> = ({
|
|
|
297
302
|
}
|
|
298
303
|
}, [locale, currency, stateLocale, stateCurrency, dispatch]);
|
|
299
304
|
|
|
300
|
-
|
|
301
|
-
const hasCards = accountData?.result?.cards && accountData.result.cards.length > 0;
|
|
305
|
+
const accountCardCount = accountData?.result?.cards?.length ?? 0;
|
|
302
306
|
|
|
307
|
+
// Keyed on the card count, not the list: reloading the same account hands
|
|
308
|
+
// out a new list, which would switch a shopper paying with a new card back to
|
|
309
|
+
// the saved cards in the middle of the payment.
|
|
310
|
+
useEffect(() => {
|
|
303
311
|
if (accountStatus?.isAccountNotLinked || accountStatus?.shouldShowDirectForm) {
|
|
304
312
|
setPaymentMethod('new_card');
|
|
305
|
-
} else if (
|
|
313
|
+
} else if (accountCardCount > 0) {
|
|
306
314
|
setPaymentMethod('stored_card');
|
|
307
315
|
} else {
|
|
308
316
|
setPaymentMethod('new_card');
|
|
309
317
|
}
|
|
310
|
-
}, [accountStatus?.isAccountNotLinked, accountStatus?.shouldShowDirectForm,
|
|
318
|
+
}, [accountStatus?.isAccountNotLinked, accountStatus?.shouldShowDirectForm, accountCardCount]);
|
|
311
319
|
|
|
312
320
|
const handleBinChange = useCallback(
|
|
313
321
|
async (bin: string) => {
|
|
314
|
-
if (bin === lastCheckedBin) {
|
|
322
|
+
if (paymentInFlightRef.current || bin === lastCheckedBin) {
|
|
315
323
|
return;
|
|
316
324
|
}
|
|
317
325
|
|
|
@@ -433,6 +441,10 @@ const MasterpassRestOption: React.FC<MasterpassRestOptionProps> = ({
|
|
|
433
441
|
if (isProcessingPayment) return;
|
|
434
442
|
|
|
435
443
|
setIsProcessingPayment(true);
|
|
444
|
+
paymentInFlightRef.current = true;
|
|
445
|
+
// Raised before the order number is requested, so theme effects that
|
|
446
|
+
// preselect a card or installment stay idle while the transaction opens.
|
|
447
|
+
setIsPaymentPending(true);
|
|
436
448
|
|
|
437
449
|
try {
|
|
438
450
|
dispatch(clearAllErrors());
|
|
@@ -462,7 +474,10 @@ const MasterpassRestOption: React.FC<MasterpassRestOptionProps> = ({
|
|
|
462
474
|
error instanceof Error ? error.message : mergedTexts.paymentFailedText;
|
|
463
475
|
dispatch(setError(errorMessage));
|
|
464
476
|
} finally {
|
|
477
|
+
paymentInFlightRef.current = false;
|
|
465
478
|
setIsProcessingPayment(false);
|
|
479
|
+
// Only a transaction that is still open (3D redirect, OTP) keeps it up.
|
|
480
|
+
setIsPaymentPending(Boolean(getPaymentPending()));
|
|
466
481
|
}
|
|
467
482
|
}, [isProcessingPayment, dispatch, paymentMethod, processDirectPayment, processPayment, updateModalState, mergedTexts.paymentFailedText]);
|
|
468
483
|
|
|
@@ -481,6 +496,7 @@ const MasterpassRestOption: React.FC<MasterpassRestOptionProps> = ({
|
|
|
481
496
|
const finalizeResult = await finalizePaymentAfterVerification(
|
|
482
497
|
result.verificationData
|
|
483
498
|
);
|
|
499
|
+
setIsPaymentPending(Boolean(getPaymentPending()));
|
|
484
500
|
|
|
485
501
|
if (!finalizeResult.success) {
|
|
486
502
|
dispatch(
|
|
@@ -500,6 +516,8 @@ const MasterpassRestOption: React.FC<MasterpassRestOptionProps> = ({
|
|
|
500
516
|
]);
|
|
501
517
|
|
|
502
518
|
const handleCardSelectWithErrorHandling = useCallback(async (card: any) => {
|
|
519
|
+
if (paymentInFlightRef.current) return;
|
|
520
|
+
|
|
503
521
|
try {
|
|
504
522
|
dispatch(clearAllErrors());
|
|
505
523
|
dismissPaymentPending();
|
|
@@ -514,6 +532,8 @@ const MasterpassRestOption: React.FC<MasterpassRestOptionProps> = ({
|
|
|
514
532
|
}, [dispatch, dismissPaymentPending, handleCardSelect, mergedTexts.failedToSelectCardText]);
|
|
515
533
|
|
|
516
534
|
const handleInstallmentSelectWithErrorHandling = useCallback(async (installment: any) => {
|
|
535
|
+
if (paymentInFlightRef.current) return;
|
|
536
|
+
|
|
517
537
|
try {
|
|
518
538
|
dispatch(clearAllErrors());
|
|
519
539
|
dismissPaymentPending();
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import React, { useEffect } from 'react';
|
|
2
|
+
import { render, screen, act } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { Provider } from 'react-redux';
|
|
5
|
+
import { configureStore } from '@reduxjs/toolkit';
|
|
6
|
+
|
|
7
|
+
import masterpassRestReducer, {
|
|
8
|
+
setMasterpassRestAccountData
|
|
9
|
+
} from '../redux/reducer';
|
|
10
|
+
import type { MasterpassRestOptionRenderProps } from '../types/custom-render.types';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* ENT-114 sequence, captured on Sportive with a shopper who has saved cards and
|
|
14
|
+
* pays with a new card:
|
|
15
|
+
*
|
|
16
|
+
* MasterpassRestOrderNoPage <- order number the SDK transaction is bound to
|
|
17
|
+
* MasterpassRestBinNumberPage <- sent 61 ms later, before the one above returns
|
|
18
|
+
* MasterpassRestInstallmentPage
|
|
19
|
+
*
|
|
20
|
+
* The payment token refresh reloaded the Masterpass account, the reloaded card
|
|
21
|
+
* list switched the view back to the saved cards, and the theme preselected the
|
|
22
|
+
* first one. The two checkout writes race on the backend and the completion
|
|
23
|
+
* carries a different order number.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
let pages: string[] = [];
|
|
27
|
+
let store: ReturnType<typeof makeStore>;
|
|
28
|
+
let sdkOrderNo: string | null = null;
|
|
29
|
+
let pageCallsWhileOrderNoPending: string[] = [];
|
|
30
|
+
let orderNoPending = false;
|
|
31
|
+
|
|
32
|
+
const SAVED_CARDS = [
|
|
33
|
+
{
|
|
34
|
+
cardAlias: 'saved card',
|
|
35
|
+
cardBin: '404308',
|
|
36
|
+
uniqueCardNumber: 'unique-1',
|
|
37
|
+
maskedCardNumber: '404308******18'
|
|
38
|
+
}
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const accountResponse = () => ({
|
|
42
|
+
statusCode: 200,
|
|
43
|
+
result: { cards: SAVED_CARDS.map((card) => ({ ...card })) }
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const recordPage = (page: string) => {
|
|
47
|
+
pages.push(page);
|
|
48
|
+
if (orderNoPending) {
|
|
49
|
+
pageCallsWhileOrderNoPending.push(page);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const INSTALLMENTS = [{ pk: 1, installment_count: 1, label: 'Tek Çekim' }];
|
|
54
|
+
|
|
55
|
+
jest.mock('../redux/api', () => ({
|
|
56
|
+
useSetMasterpassRestBinNumberMutation: () => [
|
|
57
|
+
() => ({
|
|
58
|
+
unwrap: async () => {
|
|
59
|
+
recordPage('MasterpassRestBinNumberPage');
|
|
60
|
+
return {
|
|
61
|
+
context_list: [
|
|
62
|
+
{
|
|
63
|
+
page_context: {
|
|
64
|
+
installments: INSTALLMENTS,
|
|
65
|
+
card_type: { name: 'visa', slug: 'visa', logo: '' }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
]
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}),
|
|
72
|
+
{ isLoading: false }
|
|
73
|
+
],
|
|
74
|
+
useCheckoutMasterpassInstallmentMutation: () => [
|
|
75
|
+
() => ({
|
|
76
|
+
unwrap: async () => {
|
|
77
|
+
recordPage('MasterpassRestInstallmentPage');
|
|
78
|
+
return { context_list: [] };
|
|
79
|
+
}
|
|
80
|
+
}),
|
|
81
|
+
{ isLoading: false }
|
|
82
|
+
],
|
|
83
|
+
usePrepareMasterpassOrderMutation: () => [
|
|
84
|
+
() => ({
|
|
85
|
+
unwrap: async () => {
|
|
86
|
+
recordPage('MasterpassRestOrderNoPage');
|
|
87
|
+
orderNoPending = true;
|
|
88
|
+
// What the account hook did on the storefront while this request was
|
|
89
|
+
// open: reload the same account, which hands out a new card list.
|
|
90
|
+
store.dispatch(setMasterpassRestAccountData(accountResponse() as any));
|
|
91
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
92
|
+
orderNoPending = false;
|
|
93
|
+
return {
|
|
94
|
+
pre_order: { total_amount_with_interest: '100.00' },
|
|
95
|
+
context_list: [{ page_context: { order_no: 'ORDER-1', extras: {} } }]
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}),
|
|
99
|
+
{ isLoading: false }
|
|
100
|
+
],
|
|
101
|
+
useFinalizeMasterpassOrderMutation: () => [
|
|
102
|
+
() => ({
|
|
103
|
+
unwrap: async () => {
|
|
104
|
+
recordPage('MasterpassRestCompletePage');
|
|
105
|
+
return { errors: null };
|
|
106
|
+
}
|
|
107
|
+
}),
|
|
108
|
+
{ isLoading: false }
|
|
109
|
+
],
|
|
110
|
+
useQueryMasterpassRewardsMutation: () => [
|
|
111
|
+
() => ({ unwrap: async () => ({ context_list: [] }) }),
|
|
112
|
+
{ isLoading: false }
|
|
113
|
+
],
|
|
114
|
+
useSelectMasterpassRewardsMutation: () => [
|
|
115
|
+
() => ({ unwrap: async () => ({}) }),
|
|
116
|
+
{ isLoading: false }
|
|
117
|
+
]
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
jest.mock('../services/payment', () => ({
|
|
121
|
+
PaymentService: class {
|
|
122
|
+
async processPayment(request: any) {
|
|
123
|
+
sdkOrderNo = request.orderNo;
|
|
124
|
+
return { success: true, data: { result: {} } };
|
|
125
|
+
}
|
|
126
|
+
async directPayment(request: any) {
|
|
127
|
+
sdkOrderNo = request.orderNo;
|
|
128
|
+
return { success: true, data: { result: {} } };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}));
|
|
132
|
+
|
|
133
|
+
jest.mock('../utils/response-handler', () => ({
|
|
134
|
+
handleMasterpassResponse: async () => ({ success: true }),
|
|
135
|
+
handleVerification: async () => ({ success: true, data: { result: {} } })
|
|
136
|
+
}));
|
|
137
|
+
|
|
138
|
+
jest.mock('../hooks/useMasterpassToken', () => ({
|
|
139
|
+
useMasterpassToken: () => ({
|
|
140
|
+
refetch: jest.fn(async () => ({ data: { token: 'fresh-token' } }))
|
|
141
|
+
})
|
|
142
|
+
}));
|
|
143
|
+
|
|
144
|
+
jest.mock('../hooks/useMasterpassScript', () => ({
|
|
145
|
+
useMasterpassScript: () => ({ isScriptLoaded: true })
|
|
146
|
+
}));
|
|
147
|
+
|
|
148
|
+
// Reads the account from the store like the real hook, so a reload re-renders.
|
|
149
|
+
jest.mock('../hooks/useMasterpassAccount', () => {
|
|
150
|
+
const { useSelector } = require('react-redux');
|
|
151
|
+
const stable = {
|
|
152
|
+
updateModalState: jest.fn(),
|
|
153
|
+
handleLinkConfirm: jest.fn(),
|
|
154
|
+
handleOTPSubmit: jest.fn(async () => ({ success: true })),
|
|
155
|
+
handleRemoveCard: jest.fn(),
|
|
156
|
+
confirmRemoveCard: jest.fn(),
|
|
157
|
+
handleAddCard: jest.fn(),
|
|
158
|
+
resetData: jest.fn()
|
|
159
|
+
};
|
|
160
|
+
return {
|
|
161
|
+
useMasterpassAccount: () => ({
|
|
162
|
+
...stable,
|
|
163
|
+
accountData: useSelector((state: any) => state.masterpassRest.accountData)
|
|
164
|
+
})
|
|
165
|
+
};
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Imported after the mocks so the view picks them up.
|
|
169
|
+
import MasterpassRestOption from './masterpass-rest-option';
|
|
170
|
+
|
|
171
|
+
const makeStore = () =>
|
|
172
|
+
configureStore({
|
|
173
|
+
reducer: { masterpassRest: masterpassRestReducer },
|
|
174
|
+
middleware: (getDefault) => getDefault({ serializableCheck: false })
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
/** Mirrors the Sportive theme: preselects a saved card and a new-card installment. */
|
|
178
|
+
const SportiveLikeTheme = (props: MasterpassRestOptionRenderProps) => {
|
|
179
|
+
const {
|
|
180
|
+
isPaymentPending,
|
|
181
|
+
paymentMethod,
|
|
182
|
+
hasStoredCards,
|
|
183
|
+
shouldShowDirectForm,
|
|
184
|
+
accountData,
|
|
185
|
+
paymentState,
|
|
186
|
+
handleCardSelect,
|
|
187
|
+
handleInstallmentSelect,
|
|
188
|
+
handleBinChange,
|
|
189
|
+
handlePaymentMethodChange,
|
|
190
|
+
handleProceedToPayment
|
|
191
|
+
} = props;
|
|
192
|
+
const showStoredCards =
|
|
193
|
+
paymentMethod === 'stored_card' && hasStoredCards && !shouldShowDirectForm;
|
|
194
|
+
const firstCard = accountData?.result?.cards?.[0];
|
|
195
|
+
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (isPaymentPending || !showStoredCards || paymentState?.selectedCard || !firstCard) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
void handleCardSelect(firstCard);
|
|
201
|
+
}, [isPaymentPending, showStoredCards, paymentState?.selectedCard, firstCard, handleCardSelect]);
|
|
202
|
+
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
if (
|
|
205
|
+
isPaymentPending ||
|
|
206
|
+
paymentMethod !== 'new_card' ||
|
|
207
|
+
paymentState?.selectedInstallment ||
|
|
208
|
+
!paymentState?.installments?.length
|
|
209
|
+
) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
void handleInstallmentSelect(paymentState.installments[0]);
|
|
213
|
+
}, [
|
|
214
|
+
isPaymentPending,
|
|
215
|
+
paymentMethod,
|
|
216
|
+
paymentState?.installments,
|
|
217
|
+
paymentState?.selectedInstallment,
|
|
218
|
+
handleInstallmentSelect
|
|
219
|
+
]);
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<>
|
|
223
|
+
<span data-testid="method">{paymentMethod}</span>
|
|
224
|
+
<button type="button" onClick={() => handlePaymentMethodChange('new_card')}>
|
|
225
|
+
new card
|
|
226
|
+
</button>
|
|
227
|
+
<button type="button" onClick={() => void handleBinChange('454671')}>
|
|
228
|
+
type card
|
|
229
|
+
</button>
|
|
230
|
+
<button type="button" onClick={() => void handleProceedToPayment()}>
|
|
231
|
+
pay
|
|
232
|
+
</button>
|
|
233
|
+
</>
|
|
234
|
+
);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/** A theme that preselects without looking at isPaymentPending. */
|
|
238
|
+
const UnguardedTheme = (props: MasterpassRestOptionRenderProps) => {
|
|
239
|
+
const { accountData, handleCardSelect, handleInstallmentSelect, paymentState } = props;
|
|
240
|
+
const firstCard = accountData?.result?.cards?.[0];
|
|
241
|
+
|
|
242
|
+
useEffect(() => {
|
|
243
|
+
if (!firstCard) return;
|
|
244
|
+
void handleCardSelect(firstCard);
|
|
245
|
+
if (paymentState?.installments?.length) {
|
|
246
|
+
void handleInstallmentSelect(paymentState.installments[0]);
|
|
247
|
+
}
|
|
248
|
+
// Re-runs on every account reload, like an effect keyed on the list.
|
|
249
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
250
|
+
}, [firstCard]);
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<button type="button" onClick={() => void props.handleProceedToPayment()}>
|
|
254
|
+
pay
|
|
255
|
+
</button>
|
|
256
|
+
);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const mount = (Theme: React.ComponentType<MasterpassRestOptionRenderProps>) => {
|
|
260
|
+
store = makeStore();
|
|
261
|
+
store.dispatch(setMasterpassRestAccountData(accountResponse() as any));
|
|
262
|
+
return render(
|
|
263
|
+
<Provider store={store}>
|
|
264
|
+
<MasterpassRestOption customRender={{ fullRender: (props) => <Theme {...props} /> }} />
|
|
265
|
+
</Provider>
|
|
266
|
+
);
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const settle = async (ms = 0) => {
|
|
270
|
+
await act(async () => {
|
|
271
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
272
|
+
});
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
beforeEach(() => {
|
|
276
|
+
pages = [];
|
|
277
|
+
pageCallsWhileOrderNoPending = [];
|
|
278
|
+
orderNoPending = false;
|
|
279
|
+
sdkOrderNo = null;
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe('ENT-114 checkout writes during a payment', () => {
|
|
283
|
+
it('keeps the new-card choice when the same account is reloaded', async () => {
|
|
284
|
+
mount(SportiveLikeTheme);
|
|
285
|
+
await settle();
|
|
286
|
+
|
|
287
|
+
await userEvent.click(screen.getByRole('button', { name: 'new card' }));
|
|
288
|
+
expect(screen.getByTestId('method')).toHaveTextContent('new_card');
|
|
289
|
+
|
|
290
|
+
await act(async () => {
|
|
291
|
+
store.dispatch(setMasterpassRestAccountData(accountResponse() as any));
|
|
292
|
+
});
|
|
293
|
+
await settle();
|
|
294
|
+
|
|
295
|
+
expect(screen.getByTestId('method')).toHaveTextContent('new_card');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('sends no card or installment page after the order number on the new-card flow', async () => {
|
|
299
|
+
mount(SportiveLikeTheme);
|
|
300
|
+
await settle();
|
|
301
|
+
|
|
302
|
+
// Mount preselects the saved card, as on the storefront.
|
|
303
|
+
expect(pages).toEqual(['MasterpassRestBinNumberPage', 'MasterpassRestInstallmentPage']);
|
|
304
|
+
|
|
305
|
+
await userEvent.click(screen.getByRole('button', { name: 'new card' }));
|
|
306
|
+
await act(async () => {
|
|
307
|
+
store.dispatch({
|
|
308
|
+
type: 'masterpassRest/setNewCardFormData',
|
|
309
|
+
payload: {
|
|
310
|
+
cardNumber: '4546 7112 3456 7890',
|
|
311
|
+
cardholderName: 'Test User',
|
|
312
|
+
expiryDate: '12/30',
|
|
313
|
+
cvv: '123'
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
await userEvent.click(screen.getByRole('button', { name: 'type card' }));
|
|
318
|
+
await settle();
|
|
319
|
+
|
|
320
|
+
pages = [];
|
|
321
|
+
await userEvent.click(screen.getByRole('button', { name: 'pay' }));
|
|
322
|
+
await settle(60);
|
|
323
|
+
|
|
324
|
+
expect(pages).toEqual(['MasterpassRestOrderNoPage', 'MasterpassRestCompletePage']);
|
|
325
|
+
expect(pageCallsWhileOrderNoPending).toEqual([]);
|
|
326
|
+
expect(sdkOrderNo).toBe('ORDER-1');
|
|
327
|
+
expect(screen.getByTestId('method')).toHaveTextContent('new_card');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('ignores card and installment selection while the order number is being prepared', async () => {
|
|
331
|
+
mount(UnguardedTheme);
|
|
332
|
+
await settle();
|
|
333
|
+
|
|
334
|
+
pages = [];
|
|
335
|
+
await userEvent.click(screen.getByRole('button', { name: 'pay' }));
|
|
336
|
+
await settle(60);
|
|
337
|
+
|
|
338
|
+
expect(pageCallsWhileOrderNoPending).toEqual([]);
|
|
339
|
+
expect(pages).toEqual(['MasterpassRestOrderNoPage', 'MasterpassRestCompletePage']);
|
|
340
|
+
});
|
|
341
|
+
});
|