@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akinon/pz-masterpass-rest",
3
- "version": "1.126.10",
3
+ "version": "1.126.11",
4
4
  "main": "src/index.ts",
5
5
  "types": "src/index.d.ts",
6
6
  "description": "Modern React-based Masterpass REST API integration package for ProjectZero e-commerce platform",
@@ -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 (!(data?.token && data.token !== reduxToken)) {
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
- useEffect(() => {
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 (hasCards) {
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, accountData?.result?.cards]);
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
+ });