@akinon/pz-masterpass-rest 2.0.56-rc.0 → 2.0.56
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/CHANGELOG.md +2 -6
- package/jest/__mocks__/akinon-next-components.js +26 -0
- package/jest/__mocks__/akinon-next-redux-hooks.js +6 -0
- package/jest/__mocks__/akinon-next-rtk.js +4 -0
- package/jest/__mocks__/akinon-next-utils.js +4 -0
- package/jest/__mocks__/hookform-resolvers.js +6 -0
- package/jest/__mocks__/host-store.js +16 -0
- package/jest/file-mock.js +1 -0
- package/jest/setup.ts +37 -0
- package/jest/style-mock.js +1 -0
- package/jest.config.js +46 -0
- package/package.json +5 -2
- package/src/components/credit-card-form.tsx +1 -1
- package/src/components/installment-list.tsx +1 -1
- package/src/components/otp-modal.tsx +4 -12
- package/src/hooks/use-masterpass-account-otp.test.tsx +189 -0
- package/src/hooks/use-masterpass-payment-pending.test.tsx +255 -0
- package/src/hooks/useMasterpassAccount.ts +36 -27
- package/src/hooks/useMasterpassPayment.ts +75 -11
- package/src/hooks/useMasterpassToken.ts +13 -50
- package/src/index.ts +0 -1
- package/src/redux/reducer.ts +2 -0
- package/src/types/custom-render.types.ts +4 -0
- package/src/types/payment.types.ts +3 -0
- package/src/utils/card-utils.ts +1 -1
- package/src/utils/payment-pending.test.ts +89 -0
- package/src/utils/payment-utils.ts +47 -0
- package/src/views/masterpass-rest-option.tsx +56 -6
- package/src/views/order-no-mismatch.test.tsx +434 -0
- package/src/views/payment-pending-guard.test.tsx +276 -0
- package/src/utils/wait-for-utils-ready.ts +0 -64
|
@@ -0,0 +1,434 @@
|
|
|
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 from '../redux/reducer';
|
|
8
|
+
import type { MasterpassRestOptionRenderProps } from '../types/custom-render.types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Regression cover for APS-4980.
|
|
12
|
+
*
|
|
13
|
+
* Reported request sequence, captured from a shopper who hit ORDER_NO_MISMATCH:
|
|
14
|
+
*
|
|
15
|
+
* MasterpassRestBinNumberPage
|
|
16
|
+
* MasterpassRestInstallmentPage
|
|
17
|
+
* MasterpassRestOrderNoPage <- order number the SDK transaction is bound to
|
|
18
|
+
* MasterpassRestBinNumberPage <- backend regenerates the order number
|
|
19
|
+
* MasterpassRestInstallmentPage <- backend regenerates it again
|
|
20
|
+
* MasterpassRestCompletePage <- arrives with the order number from step 3
|
|
21
|
+
*
|
|
22
|
+
* Steps 4 and 5 are not shopper actions: they are the theme preselecting the
|
|
23
|
+
* first saved card and the first installment when checkout mounts again after
|
|
24
|
+
* the shopper leaves for 3D and comes back. These tests pin the request
|
|
25
|
+
* sequence so that regression cannot return unnoticed.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
let pages: string[] = [];
|
|
29
|
+
let orderNoCounter = 0;
|
|
30
|
+
let currentOrderNo = '';
|
|
31
|
+
|
|
32
|
+
const recordPage = (page: string) => {
|
|
33
|
+
pages.push(page);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Mirrors the backend: every bin/installment call re-derives the order number.
|
|
37
|
+
const regenerateOrderNo = () => {
|
|
38
|
+
orderNoCounter += 1;
|
|
39
|
+
currentOrderNo = `ORDER-${orderNoCounter}`;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const completeCalls: Array<{ orderNoAtBackend: string }> = [];
|
|
43
|
+
|
|
44
|
+
jest.mock('../redux/api', () => ({
|
|
45
|
+
useSetMasterpassRestBinNumberMutation: () => [
|
|
46
|
+
() => ({
|
|
47
|
+
unwrap: async () => {
|
|
48
|
+
recordPage('MasterpassRestBinNumberPage');
|
|
49
|
+
regenerateOrderNo();
|
|
50
|
+
return {
|
|
51
|
+
context_list: [
|
|
52
|
+
{
|
|
53
|
+
page_context: {
|
|
54
|
+
installments: [{ pk: 1, installment_count: 1, label: 'Tek Çekim' }],
|
|
55
|
+
card_type: { name: 'visa', slug: 'visa', logo: '' }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}),
|
|
62
|
+
{ isLoading: false }
|
|
63
|
+
],
|
|
64
|
+
useCheckoutMasterpassInstallmentMutation: () => [
|
|
65
|
+
() => ({
|
|
66
|
+
unwrap: async () => {
|
|
67
|
+
recordPage('MasterpassRestInstallmentPage');
|
|
68
|
+
regenerateOrderNo();
|
|
69
|
+
return { context_list: [] };
|
|
70
|
+
}
|
|
71
|
+
}),
|
|
72
|
+
{ isLoading: false }
|
|
73
|
+
],
|
|
74
|
+
usePrepareMasterpassOrderMutation: () => [
|
|
75
|
+
() => ({
|
|
76
|
+
unwrap: async () => {
|
|
77
|
+
recordPage('MasterpassRestOrderNoPage');
|
|
78
|
+
return {
|
|
79
|
+
pre_order: { total_amount_with_interest: '100.00' },
|
|
80
|
+
context_list: [{ page_context: { order_no: currentOrderNo, extras: {} } }]
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}),
|
|
84
|
+
{ isLoading: false }
|
|
85
|
+
],
|
|
86
|
+
useFinalizeMasterpassOrderMutation: () => [
|
|
87
|
+
() => ({
|
|
88
|
+
unwrap: async () => {
|
|
89
|
+
recordPage('MasterpassRestCompletePage');
|
|
90
|
+
completeCalls.push({ orderNoAtBackend: currentOrderNo });
|
|
91
|
+
return { errors: null };
|
|
92
|
+
}
|
|
93
|
+
}),
|
|
94
|
+
{ isLoading: false }
|
|
95
|
+
],
|
|
96
|
+
useQueryMasterpassRewardsMutation: () => [
|
|
97
|
+
() => ({ unwrap: async () => ({ context_list: [] }) }),
|
|
98
|
+
{ isLoading: false }
|
|
99
|
+
],
|
|
100
|
+
useSelectMasterpassRewardsMutation: () => [
|
|
101
|
+
() => ({ unwrap: async () => ({}) }),
|
|
102
|
+
{ isLoading: false }
|
|
103
|
+
]
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
// The order number the SDK was handed when the transaction opened.
|
|
107
|
+
let orderNoHeldBySdk: string | null = null;
|
|
108
|
+
let sdkOutcome: any = { success: true };
|
|
109
|
+
|
|
110
|
+
jest.mock('../services/payment', () => ({
|
|
111
|
+
PaymentService: class {
|
|
112
|
+
async processPayment(request: any) {
|
|
113
|
+
orderNoHeldBySdk = request.orderNo;
|
|
114
|
+
return sdkOutcome;
|
|
115
|
+
}
|
|
116
|
+
async directPayment(request: any) {
|
|
117
|
+
orderNoHeldBySdk = request.orderNo;
|
|
118
|
+
return sdkOutcome;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}));
|
|
122
|
+
|
|
123
|
+
let responseHandlerResult: any = { success: true };
|
|
124
|
+
jest.mock('../utils/response-handler', () => ({
|
|
125
|
+
handleMasterpassResponse: async () => responseHandlerResult,
|
|
126
|
+
handleVerification: async () => ({ success: true, data: { result: {} } })
|
|
127
|
+
}));
|
|
128
|
+
|
|
129
|
+
jest.mock('../hooks/useMasterpassToken', () => ({
|
|
130
|
+
useMasterpassToken: () => ({
|
|
131
|
+
refetch: jest.fn(async () => ({ data: { token: 'fresh-token' } }))
|
|
132
|
+
})
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
jest.mock('../hooks/useMasterpassScript', () => ({
|
|
136
|
+
useMasterpassScript: () => ({ isScriptLoaded: true })
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
jest.mock('@akinon/next/utils', () => ({
|
|
140
|
+
getPosError: () => null,
|
|
141
|
+
buildClientRequestUrl: (url: string) => url
|
|
142
|
+
}));
|
|
143
|
+
|
|
144
|
+
const STORED_CARD = {
|
|
145
|
+
cardAlias: 'my card',
|
|
146
|
+
cardBin: '454671',
|
|
147
|
+
uniqueCardNumber: 'unique-1',
|
|
148
|
+
maskedCardNumber: '4546 **** **** 7890'
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const accountHookValue = {
|
|
152
|
+
accountData: { result: { cards: [STORED_CARD] } },
|
|
153
|
+
updateModalState: jest.fn(),
|
|
154
|
+
handleLinkConfirm: jest.fn(),
|
|
155
|
+
handleOTPSubmit: jest.fn(async () => ({ success: true })),
|
|
156
|
+
handleRemoveCard: jest.fn(),
|
|
157
|
+
confirmRemoveCard: jest.fn(),
|
|
158
|
+
handleAddCard: jest.fn(),
|
|
159
|
+
resetData: jest.fn()
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
jest.mock('../hooks/useMasterpassAccount', () => ({
|
|
163
|
+
useMasterpassAccount: () => accountHookValue
|
|
164
|
+
}));
|
|
165
|
+
|
|
166
|
+
// Imported after the mocks so the view picks them up.
|
|
167
|
+
import MasterpassRestOption from './masterpass-rest-option';
|
|
168
|
+
|
|
169
|
+
/** Reproduces the preselection a brand theme performs on mount. */
|
|
170
|
+
const ThemeWithAutoSelect = (props: MasterpassRestOptionRenderProps) => {
|
|
171
|
+
const {
|
|
172
|
+
isPaymentPending,
|
|
173
|
+
handleCardSelect,
|
|
174
|
+
handleInstallmentSelect,
|
|
175
|
+
accountData,
|
|
176
|
+
paymentState
|
|
177
|
+
} = props;
|
|
178
|
+
const firstCard = accountData?.result?.cards?.[0];
|
|
179
|
+
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
if (isPaymentPending || paymentState?.selectedCard || !firstCard) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
void handleCardSelect(firstCard);
|
|
185
|
+
}, [isPaymentPending, paymentState?.selectedCard, firstCard, handleCardSelect]);
|
|
186
|
+
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (
|
|
189
|
+
isPaymentPending ||
|
|
190
|
+
paymentState?.selectedInstallment ||
|
|
191
|
+
!paymentState?.installments?.length
|
|
192
|
+
) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
void handleInstallmentSelect(paymentState.installments[0]);
|
|
196
|
+
}, [
|
|
197
|
+
isPaymentPending,
|
|
198
|
+
paymentState?.installments,
|
|
199
|
+
paymentState?.selectedInstallment,
|
|
200
|
+
handleInstallmentSelect
|
|
201
|
+
]);
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<>
|
|
205
|
+
<button type="button" onClick={() => void handleCardSelect(firstCard)}>
|
|
206
|
+
choose card
|
|
207
|
+
</button>
|
|
208
|
+
<button type="button" onClick={() => void props.handleProceedToPayment()}>
|
|
209
|
+
pay
|
|
210
|
+
</button>
|
|
211
|
+
</>
|
|
212
|
+
);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/** Each render is a fresh checkout mount, exactly like a page load. */
|
|
216
|
+
const mountCheckout = () =>
|
|
217
|
+
render(
|
|
218
|
+
<Provider
|
|
219
|
+
store={configureStore({
|
|
220
|
+
reducer: { masterpassRest: masterpassRestReducer },
|
|
221
|
+
middleware: (getDefault) => getDefault({ serializableCheck: false })
|
|
222
|
+
})}
|
|
223
|
+
>
|
|
224
|
+
<MasterpassRestOption
|
|
225
|
+
customRender={{ fullRender: (props) => <ThemeWithAutoSelect {...props} /> }}
|
|
226
|
+
/>
|
|
227
|
+
</Provider>
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const settle = async () => {
|
|
231
|
+
await act(async () => {
|
|
232
|
+
await Promise.resolve();
|
|
233
|
+
await Promise.resolve();
|
|
234
|
+
});
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const stubNavigation = () => {
|
|
238
|
+
const location = window.location;
|
|
239
|
+
delete (window as any).location;
|
|
240
|
+
(window as any).location = { ...location, href: '' };
|
|
241
|
+
return () => {
|
|
242
|
+
(window as any).location = location;
|
|
243
|
+
};
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
beforeEach(() => {
|
|
247
|
+
pages = [];
|
|
248
|
+
orderNoCounter = 0;
|
|
249
|
+
currentOrderNo = '';
|
|
250
|
+
completeCalls.length = 0;
|
|
251
|
+
orderNoHeldBySdk = null;
|
|
252
|
+
sdkOutcome = { success: true };
|
|
253
|
+
responseHandlerResult = { success: true };
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe('APS-4980 request sequence', () => {
|
|
257
|
+
it('does not re-derive the order number when checkout remounts after a 3D redirect', async () => {
|
|
258
|
+
responseHandlerResult = {
|
|
259
|
+
requires3D: true,
|
|
260
|
+
redirectUrl: 'https://bank.example/3d'
|
|
261
|
+
};
|
|
262
|
+
const restoreNavigation = stubNavigation();
|
|
263
|
+
|
|
264
|
+
const first = mountCheckout();
|
|
265
|
+
await settle();
|
|
266
|
+
|
|
267
|
+
expect(pages).toEqual([
|
|
268
|
+
'MasterpassRestBinNumberPage',
|
|
269
|
+
'MasterpassRestInstallmentPage'
|
|
270
|
+
]);
|
|
271
|
+
|
|
272
|
+
await userEvent.click(screen.getByRole('button', { name: 'pay' }));
|
|
273
|
+
await settle();
|
|
274
|
+
|
|
275
|
+
expect(pages).toEqual([
|
|
276
|
+
'MasterpassRestBinNumberPage',
|
|
277
|
+
'MasterpassRestInstallmentPage',
|
|
278
|
+
'MasterpassRestOrderNoPage'
|
|
279
|
+
]);
|
|
280
|
+
const orderNoBoundToTransaction = orderNoHeldBySdk;
|
|
281
|
+
expect(orderNoBoundToTransaction).toBe(currentOrderNo);
|
|
282
|
+
|
|
283
|
+
// The shopper leaves for the bank and comes back: unmount, then mount again.
|
|
284
|
+
first.unmount();
|
|
285
|
+
mountCheckout();
|
|
286
|
+
await settle();
|
|
287
|
+
|
|
288
|
+
// The reported bug added two more pages here, moving the backend's order
|
|
289
|
+
// number away from the one the open transaction carries.
|
|
290
|
+
expect(pages).toEqual([
|
|
291
|
+
'MasterpassRestBinNumberPage',
|
|
292
|
+
'MasterpassRestInstallmentPage',
|
|
293
|
+
'MasterpassRestOrderNoPage'
|
|
294
|
+
]);
|
|
295
|
+
expect(currentOrderNo).toBe(orderNoBoundToTransaction);
|
|
296
|
+
|
|
297
|
+
restoreNavigation();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('reproduces the mismatch when the remount is left unguarded', async () => {
|
|
301
|
+
// Same flow with the guard disabled, to show the test would have caught the
|
|
302
|
+
// original defect rather than passing either way.
|
|
303
|
+
responseHandlerResult = {
|
|
304
|
+
requires3D: true,
|
|
305
|
+
redirectUrl: 'https://bank.example/3d'
|
|
306
|
+
};
|
|
307
|
+
const restoreNavigation = stubNavigation();
|
|
308
|
+
|
|
309
|
+
const UnguardedTheme = (props: MasterpassRestOptionRenderProps) => {
|
|
310
|
+
const { handleCardSelect, accountData, paymentState } = props;
|
|
311
|
+
const firstCard = accountData?.result?.cards?.[0];
|
|
312
|
+
|
|
313
|
+
useEffect(() => {
|
|
314
|
+
if (paymentState?.selectedCard || !firstCard) return;
|
|
315
|
+
void handleCardSelect(firstCard);
|
|
316
|
+
}, [paymentState?.selectedCard, firstCard, handleCardSelect]);
|
|
317
|
+
|
|
318
|
+
return (
|
|
319
|
+
<button type="button" onClick={() => void props.handleProceedToPayment()}>
|
|
320
|
+
pay
|
|
321
|
+
</button>
|
|
322
|
+
);
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const mountUnguarded = () =>
|
|
326
|
+
render(
|
|
327
|
+
<Provider
|
|
328
|
+
store={configureStore({
|
|
329
|
+
reducer: { masterpassRest: masterpassRestReducer },
|
|
330
|
+
middleware: (getDefault) => getDefault({ serializableCheck: false })
|
|
331
|
+
})}
|
|
332
|
+
>
|
|
333
|
+
<MasterpassRestOption
|
|
334
|
+
customRender={{ fullRender: (props) => <UnguardedTheme {...props} /> }}
|
|
335
|
+
/>
|
|
336
|
+
</Provider>
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const first = mountUnguarded();
|
|
340
|
+
await settle();
|
|
341
|
+
await userEvent.click(screen.getByRole('button', { name: 'pay' }));
|
|
342
|
+
await settle();
|
|
343
|
+
|
|
344
|
+
const orderNoBoundToTransaction = orderNoHeldBySdk;
|
|
345
|
+
|
|
346
|
+
first.unmount();
|
|
347
|
+
mountUnguarded();
|
|
348
|
+
await settle();
|
|
349
|
+
|
|
350
|
+
expect(pages).toEqual([
|
|
351
|
+
'MasterpassRestBinNumberPage',
|
|
352
|
+
'MasterpassRestInstallmentPage',
|
|
353
|
+
'MasterpassRestOrderNoPage',
|
|
354
|
+
'MasterpassRestBinNumberPage',
|
|
355
|
+
'MasterpassRestInstallmentPage'
|
|
356
|
+
]);
|
|
357
|
+
expect(currentOrderNo).not.toBe(orderNoBoundToTransaction);
|
|
358
|
+
|
|
359
|
+
restoreNavigation();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('completes against the same order number the transaction was opened with', async () => {
|
|
363
|
+
mountCheckout();
|
|
364
|
+
await settle();
|
|
365
|
+
|
|
366
|
+
await userEvent.click(screen.getByRole('button', { name: 'pay' }));
|
|
367
|
+
await settle();
|
|
368
|
+
|
|
369
|
+
expect(pages).toEqual([
|
|
370
|
+
'MasterpassRestBinNumberPage',
|
|
371
|
+
'MasterpassRestInstallmentPage',
|
|
372
|
+
'MasterpassRestOrderNoPage',
|
|
373
|
+
'MasterpassRestCompletePage'
|
|
374
|
+
]);
|
|
375
|
+
expect(completeCalls).toHaveLength(1);
|
|
376
|
+
expect(completeCalls[0].orderNoAtBackend).toBe(orderNoHeldBySdk);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('leaves the shopper to pick a card before a new attempt can start', async () => {
|
|
380
|
+
responseHandlerResult = {
|
|
381
|
+
requires3D: true,
|
|
382
|
+
redirectUrl: 'https://bank.example/3d'
|
|
383
|
+
};
|
|
384
|
+
const restoreNavigation = stubNavigation();
|
|
385
|
+
|
|
386
|
+
const first = mountCheckout();
|
|
387
|
+
await settle();
|
|
388
|
+
await userEvent.click(screen.getByRole('button', { name: 'pay' }));
|
|
389
|
+
await settle();
|
|
390
|
+
|
|
391
|
+
first.unmount();
|
|
392
|
+
mountCheckout();
|
|
393
|
+
await settle();
|
|
394
|
+
|
|
395
|
+
// Nothing is preselected, so paying straight away cannot open a second
|
|
396
|
+
// transaction behind the one still in flight.
|
|
397
|
+
await userEvent.click(screen.getByRole('button', { name: 'pay' }));
|
|
398
|
+
await settle();
|
|
399
|
+
|
|
400
|
+
expect(pages.filter((page) => page === 'MasterpassRestOrderNoPage')).toHaveLength(1);
|
|
401
|
+
|
|
402
|
+
restoreNavigation();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('re-derives the order number once the shopper deliberately retries', async () => {
|
|
406
|
+
responseHandlerResult = {
|
|
407
|
+
requires3D: true,
|
|
408
|
+
redirectUrl: 'https://bank.example/3d'
|
|
409
|
+
};
|
|
410
|
+
const restoreNavigation = stubNavigation();
|
|
411
|
+
|
|
412
|
+
const first = mountCheckout();
|
|
413
|
+
await settle();
|
|
414
|
+
await userEvent.click(screen.getByRole('button', { name: 'pay' }));
|
|
415
|
+
await settle();
|
|
416
|
+
|
|
417
|
+
first.unmount();
|
|
418
|
+
|
|
419
|
+
// Choosing a card again is an explicit new attempt, so a fresh order number
|
|
420
|
+
// is the correct outcome here.
|
|
421
|
+
responseHandlerResult = { success: true };
|
|
422
|
+
mountCheckout();
|
|
423
|
+
await settle();
|
|
424
|
+
await userEvent.click(screen.getByRole('button', { name: 'choose card' }));
|
|
425
|
+
await settle();
|
|
426
|
+
await userEvent.click(screen.getByRole('button', { name: 'pay' }));
|
|
427
|
+
await settle();
|
|
428
|
+
|
|
429
|
+
expect(pages.filter((page) => page === 'MasterpassRestOrderNoPage')).toHaveLength(2);
|
|
430
|
+
expect(completeCalls[0].orderNoAtBackend).toBe(orderNoHeldBySdk);
|
|
431
|
+
|
|
432
|
+
restoreNavigation();
|
|
433
|
+
});
|
|
434
|
+
});
|
|
@@ -0,0 +1,276 @@
|
|
|
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 from '../redux/reducer';
|
|
8
|
+
import { setPaymentPending, getPaymentPending } from '../utils/payment-utils';
|
|
9
|
+
import type { MasterpassRestOptionRenderProps } from '../types/custom-render.types';
|
|
10
|
+
|
|
11
|
+
const handleCardSelectMock = jest.fn();
|
|
12
|
+
const handleInstallmentSelectMock = jest.fn();
|
|
13
|
+
const processPaymentMock = jest.fn();
|
|
14
|
+
const processDirectPaymentMock = jest.fn();
|
|
15
|
+
const finalizeAfterVerificationMock = jest.fn();
|
|
16
|
+
const updateModalStateMock = jest.fn();
|
|
17
|
+
const handleOTPSubmitMock = jest.fn();
|
|
18
|
+
const getPosErrorMock = jest.fn(() => null as any);
|
|
19
|
+
|
|
20
|
+
jest.mock('@akinon/next/utils', () => ({
|
|
21
|
+
getPosError: () => getPosErrorMock(),
|
|
22
|
+
buildClientRequestUrl: (url: string) => url
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
jest.mock('../redux/api', () => ({
|
|
26
|
+
useSetMasterpassRestBinNumberMutation: () => [
|
|
27
|
+
() => ({ unwrap: async () => ({ context_list: [] }) }),
|
|
28
|
+
{ isLoading: false }
|
|
29
|
+
]
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
jest.mock('../hooks/useMasterpassScript', () => ({
|
|
33
|
+
useMasterpassScript: () => ({ isScriptLoaded: true })
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
// The real hooks memoize everything they hand out. The mocks return one frozen
|
|
37
|
+
// object so identities stay stable across renders, otherwise the view's effects
|
|
38
|
+
// re-run on every render and mask the behavior under test.
|
|
39
|
+
const paymentHookValue = {
|
|
40
|
+
isCheckoutLoading: false,
|
|
41
|
+
isInstallmentLoading: false,
|
|
42
|
+
isPrepareLoading: false,
|
|
43
|
+
isFinalizeLoading: false,
|
|
44
|
+
isRewardsQueryLoading: false,
|
|
45
|
+
isRewardsSelectLoading: false,
|
|
46
|
+
payableAmount: '100.00',
|
|
47
|
+
updatePaymentState: jest.fn(),
|
|
48
|
+
handleCardSelect: handleCardSelectMock,
|
|
49
|
+
handleInstallmentSelect: handleInstallmentSelectMock,
|
|
50
|
+
processPayment: processPaymentMock,
|
|
51
|
+
processDirectPayment: processDirectPaymentMock,
|
|
52
|
+
finalizePaymentAfterVerification: finalizeAfterVerificationMock,
|
|
53
|
+
fetchRewardsForCard: jest.fn(),
|
|
54
|
+
openRewardModal: jest.fn(),
|
|
55
|
+
closeRewardModal: jest.fn(),
|
|
56
|
+
confirmRewards: jest.fn()
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
jest.mock('../hooks/useMasterpassPayment', () => ({
|
|
60
|
+
useMasterpassPayment: () => paymentHookValue
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
const STORED_CARD = {
|
|
64
|
+
cardAlias: 'my card',
|
|
65
|
+
cardBin: '454671',
|
|
66
|
+
uniqueCardNumber: 'unique-1',
|
|
67
|
+
maskedCardNumber: '4546 **** **** 7890'
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const accountHookValue = {
|
|
71
|
+
accountData: { result: { cards: [STORED_CARD] } },
|
|
72
|
+
updateModalState: updateModalStateMock,
|
|
73
|
+
handleLinkConfirm: jest.fn(),
|
|
74
|
+
handleOTPSubmit: handleOTPSubmitMock,
|
|
75
|
+
handleRemoveCard: jest.fn(),
|
|
76
|
+
confirmRemoveCard: jest.fn(),
|
|
77
|
+
handleAddCard: jest.fn(),
|
|
78
|
+
resetData: jest.fn()
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
jest.mock('../hooks/useMasterpassAccount', () => ({
|
|
82
|
+
useMasterpassAccount: () => accountHookValue
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
// Imported after the mocks so the view picks them up.
|
|
86
|
+
import MasterpassRestOption from './masterpass-rest-option';
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Stands in for a brand theme. Themes preselect the first saved card on mount,
|
|
90
|
+
* which is what silently regenerated the order number after a shopper came back
|
|
91
|
+
* from the bank. The guard the theme is expected to honour is `isPaymentPending`.
|
|
92
|
+
*/
|
|
93
|
+
const ThemeWithAutoSelect = (props: MasterpassRestOptionRenderProps) => {
|
|
94
|
+
const { isPaymentPending, handleCardSelect, accountData, paymentState } = props;
|
|
95
|
+
const firstCard = accountData?.result?.cards?.[0];
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (isPaymentPending || paymentState?.selectedCard || !firstCard) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
void handleCardSelect(firstCard);
|
|
103
|
+
}, [isPaymentPending, paymentState?.selectedCard, firstCard, handleCardSelect]);
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div>
|
|
107
|
+
<span data-testid="pending">{String(isPaymentPending)}</span>
|
|
108
|
+
<button type="button" onClick={() => void handleCardSelect(firstCard)}>
|
|
109
|
+
choose card
|
|
110
|
+
</button>
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
onClick={() => props.handlePaymentMethodChange('new_card')}
|
|
114
|
+
>
|
|
115
|
+
use another card
|
|
116
|
+
</button>
|
|
117
|
+
<button type="button" onClick={() => void props.handleProceedToPayment()}>
|
|
118
|
+
pay
|
|
119
|
+
</button>
|
|
120
|
+
<button type="button" onClick={() => void props.handleOTPSubmit('123456')}>
|
|
121
|
+
verify
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const renderOption = () => {
|
|
128
|
+
const store = configureStore({
|
|
129
|
+
reducer: { masterpassRest: masterpassRestReducer },
|
|
130
|
+
middleware: (getDefault) => getDefault({ serializableCheck: false })
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return render(
|
|
134
|
+
<Provider store={store}>
|
|
135
|
+
<MasterpassRestOption
|
|
136
|
+
customRender={{ fullRender: (props) => <ThemeWithAutoSelect {...props} /> }}
|
|
137
|
+
/>
|
|
138
|
+
</Provider>
|
|
139
|
+
);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
beforeEach(() => {
|
|
143
|
+
getPosErrorMock.mockReturnValue(null);
|
|
144
|
+
processPaymentMock.mockResolvedValue({ success: true });
|
|
145
|
+
handleOTPSubmitMock.mockResolvedValue({ success: true });
|
|
146
|
+
finalizeAfterVerificationMock.mockResolvedValue({ success: true });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('returning to checkout while a transaction is still open', () => {
|
|
150
|
+
it('suppresses the automatic card preselection that would change the order number', async () => {
|
|
151
|
+
setPaymentPending('ORDER-1');
|
|
152
|
+
|
|
153
|
+
renderOption();
|
|
154
|
+
|
|
155
|
+
expect(screen.getByTestId('pending')).toHaveTextContent('true');
|
|
156
|
+
expect(handleCardSelectMock).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('preselects a card normally when no transaction is open', async () => {
|
|
160
|
+
renderOption();
|
|
161
|
+
|
|
162
|
+
expect(screen.getByTestId('pending')).toHaveTextContent('false');
|
|
163
|
+
expect(handleCardSelectMock).toHaveBeenCalledWith(STORED_CARD);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('still lets the shopper start over by choosing a card themselves', async () => {
|
|
167
|
+
setPaymentPending('ORDER-1');
|
|
168
|
+
|
|
169
|
+
renderOption();
|
|
170
|
+
expect(handleCardSelectMock).not.toHaveBeenCalled();
|
|
171
|
+
|
|
172
|
+
await userEvent.click(screen.getByRole('button', { name: 'choose card' }));
|
|
173
|
+
|
|
174
|
+
expect(handleCardSelectMock).toHaveBeenCalledWith(STORED_CARD);
|
|
175
|
+
expect(getPaymentPending()).toBeNull();
|
|
176
|
+
expect(screen.getByTestId('pending')).toHaveTextContent('false');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('releases the guard when the shopper switches to a new card', async () => {
|
|
180
|
+
setPaymentPending('ORDER-1');
|
|
181
|
+
|
|
182
|
+
renderOption();
|
|
183
|
+
|
|
184
|
+
await userEvent.click(screen.getByRole('button', { name: 'use another card' }));
|
|
185
|
+
|
|
186
|
+
expect(getPaymentPending()).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('releases the guard when the completion request came back with an error', async () => {
|
|
190
|
+
setPaymentPending('ORDER-1');
|
|
191
|
+
getPosErrorMock.mockReturnValue({ non_field_errors: 'ORDER_NO_MISMATCH' });
|
|
192
|
+
|
|
193
|
+
renderOption();
|
|
194
|
+
|
|
195
|
+
expect(getPaymentPending()).toBeNull();
|
|
196
|
+
expect(screen.getByTestId('pending')).toHaveTextContent('false');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('releases the guard once the record has aged out', async () => {
|
|
200
|
+
window.sessionStorage.setItem(
|
|
201
|
+
'masterpass_rest_payment_pending',
|
|
202
|
+
JSON.stringify({ orderNo: 'ORDER-1', startedAt: Date.now() - 31 * 60 * 1000 })
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
renderOption();
|
|
206
|
+
|
|
207
|
+
expect(screen.getByTestId('pending')).toHaveTextContent('false');
|
|
208
|
+
expect(handleCardSelectMock).toHaveBeenCalledWith(STORED_CARD);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe('a payment that needs verification', () => {
|
|
213
|
+
it('marks the verification as belonging to the payment, not the account', async () => {
|
|
214
|
+
processPaymentMock.mockResolvedValue({
|
|
215
|
+
requiresOTP: true,
|
|
216
|
+
otpType: 'OTP'
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
renderOption();
|
|
220
|
+
|
|
221
|
+
await userEvent.click(screen.getByRole('button', { name: 'pay' }));
|
|
222
|
+
|
|
223
|
+
expect(updateModalStateMock).toHaveBeenCalledWith({
|
|
224
|
+
showOTPModal: true,
|
|
225
|
+
otpType: 'OTP',
|
|
226
|
+
otpContext: 'payment'
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('completes the order against the backend once verification succeeds', async () => {
|
|
231
|
+
const verificationData = { result: { responseCode: '0000', token: 'verified' } };
|
|
232
|
+
handleOTPSubmitMock.mockResolvedValue({
|
|
233
|
+
success: true,
|
|
234
|
+
isPaymentContext: true,
|
|
235
|
+
verificationData
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
renderOption();
|
|
239
|
+
|
|
240
|
+
await userEvent.click(screen.getByRole('button', { name: 'verify' }));
|
|
241
|
+
|
|
242
|
+
expect(finalizeAfterVerificationMock).toHaveBeenCalledWith(verificationData);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('does not complete an order for an account-only verification', async () => {
|
|
246
|
+
handleOTPSubmitMock.mockResolvedValue({ success: true });
|
|
247
|
+
|
|
248
|
+
renderOption();
|
|
249
|
+
|
|
250
|
+
await userEvent.click(screen.getByRole('button', { name: 'verify' }));
|
|
251
|
+
|
|
252
|
+
expect(finalizeAfterVerificationMock).not.toHaveBeenCalled();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('reports a failed completion instead of letting it pass silently', async () => {
|
|
256
|
+
handleOTPSubmitMock.mockResolvedValue({
|
|
257
|
+
success: true,
|
|
258
|
+
isPaymentContext: true,
|
|
259
|
+
verificationData: { result: {} }
|
|
260
|
+
});
|
|
261
|
+
finalizeAfterVerificationMock.mockResolvedValue({
|
|
262
|
+
success: false,
|
|
263
|
+
message: 'ORDER_NO_MISMATCH'
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
renderOption();
|
|
267
|
+
|
|
268
|
+
await userEvent.click(screen.getByRole('button', { name: 'verify' }));
|
|
269
|
+
// The error dispatch lands after the finalize promise settles.
|
|
270
|
+
await act(async () => {
|
|
271
|
+
await Promise.resolve();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
expect(finalizeAfterVerificationMock).toHaveBeenCalled();
|
|
275
|
+
});
|
|
276
|
+
});
|