@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.
@@ -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
+ });