@akinon/pz-masterpass-rest 2.0.56-beta.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 +5 -1
- 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 +4 -1
- 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 +30 -1
- package/src/hooks/useMasterpassPayment.ts +67 -3
- 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/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/CHANGELOG.md
CHANGED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const React = require('react');
|
|
2
|
+
|
|
3
|
+
// Minimal stand-ins: tests assert on this package's behavior, not on how the
|
|
4
|
+
// design system renders.
|
|
5
|
+
const passthrough = (tag) => (props) => {
|
|
6
|
+
const { children, ...rest } = props || {};
|
|
7
|
+
return React.createElement(tag, rest, children);
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const Modal = ({ open, children }) =>
|
|
11
|
+
open ? React.createElement('div', { 'data-testid': 'modal' }, children) : null;
|
|
12
|
+
|
|
13
|
+
module.exports = {
|
|
14
|
+
Icon: (props) => React.createElement('i', { 'data-icon': props?.name }),
|
|
15
|
+
Button: passthrough('button'),
|
|
16
|
+
Input: (props) => {
|
|
17
|
+
const { error, label, ...rest } = props || {};
|
|
18
|
+
return React.createElement('input', rest);
|
|
19
|
+
},
|
|
20
|
+
LoaderSpinner: () => React.createElement('div', { role: 'status' }),
|
|
21
|
+
Modal,
|
|
22
|
+
Price: (props) => React.createElement('span', null, String(props?.value ?? '')),
|
|
23
|
+
Radio: passthrough('input'),
|
|
24
|
+
Checkbox: passthrough('input'),
|
|
25
|
+
Image: (props) => React.createElement('img', { alt: props?.alt, src: props?.src })
|
|
26
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The package reads the host app's store directly in its service layer. Tests
|
|
2
|
+
// only need the masterpassRest slice the services touch.
|
|
3
|
+
const state = {
|
|
4
|
+
masterpassRest: {
|
|
5
|
+
token: 'test-token',
|
|
6
|
+
tokenData: { MerchantId: 'merchant-1' },
|
|
7
|
+
modalState: { verificationData: null }
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
module.exports = {
|
|
12
|
+
store: {
|
|
13
|
+
getState: () => state,
|
|
14
|
+
dispatch: jest.fn()
|
|
15
|
+
}
|
|
16
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = { src: '/test-asset.png', height: 1, width: 1 };
|
package/jest/setup.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import '@testing-library/jest-dom';
|
|
2
|
+
|
|
3
|
+
// React needs this flag to accept act(...) outside of a render call.
|
|
4
|
+
(global as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
5
|
+
|
|
6
|
+
// The SDK is injected by useMasterpassScript at runtime. Tests never exercise
|
|
7
|
+
// the real script, but service constructors call these setters eagerly.
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
(window as any).Masterpass = {
|
|
10
|
+
setToken: jest.fn(),
|
|
11
|
+
setMerchantId: jest.fn(),
|
|
12
|
+
paymentService: {
|
|
13
|
+
payment: jest.fn(),
|
|
14
|
+
directPayment: jest.fn()
|
|
15
|
+
},
|
|
16
|
+
verifyService: {
|
|
17
|
+
verifyOtp: jest.fn(),
|
|
18
|
+
resendOtp: jest.fn()
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
(window as any).Utils = {
|
|
23
|
+
decodeJwt: jest.fn(() => ({
|
|
24
|
+
MerchantId: 'merchant-1',
|
|
25
|
+
AccountKey: 'account-1',
|
|
26
|
+
AuthenticationMethod: '3DS',
|
|
27
|
+
Secure3dType: '3D',
|
|
28
|
+
UserId: 'user-1'
|
|
29
|
+
}))
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
window.sessionStorage.clear();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
jest.clearAllMocks();
|
|
37
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = {};
|
package/jest.config.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
|
2
|
+
module.exports = {
|
|
3
|
+
preset: 'ts-jest',
|
|
4
|
+
testEnvironment: 'jsdom',
|
|
5
|
+
roots: ['<rootDir>/src'],
|
|
6
|
+
testMatch: ['**/*.test.ts', '**/*.test.tsx'],
|
|
7
|
+
setupFilesAfterEnv: ['<rootDir>/jest/setup.ts'],
|
|
8
|
+
// The hooks and the option view pull in app-coupled modules (@akinon/next
|
|
9
|
+
// components, the host app's redux store, image assets). Each is mapped to a
|
|
10
|
+
// small stub so tests exercise this package's own logic.
|
|
11
|
+
moduleNameMapper: {
|
|
12
|
+
'\\.(css|less|scss|sass)$': '<rootDir>/jest/style-mock.js',
|
|
13
|
+
'\\.(png|jpg|jpeg|gif|svg|webp)$': '<rootDir>/jest/file-mock.js',
|
|
14
|
+
'^redux/store$': '<rootDir>/jest/__mocks__/host-store.js',
|
|
15
|
+
'^@hookform/resolvers(/.*)?$': '<rootDir>/jest/__mocks__/hookform-resolvers.js',
|
|
16
|
+
'^@akinon/next/redux/hooks$':
|
|
17
|
+
'<rootDir>/jest/__mocks__/akinon-next-redux-hooks.js',
|
|
18
|
+
'^@akinon/next/components$':
|
|
19
|
+
'<rootDir>/jest/__mocks__/akinon-next-components.js',
|
|
20
|
+
'^@akinon/next/utils$': '<rootDir>/jest/__mocks__/akinon-next-utils.js',
|
|
21
|
+
'^@akinon/next/data/client/(.*)$': '<rootDir>/jest/__mocks__/akinon-next-rtk.js'
|
|
22
|
+
},
|
|
23
|
+
transform: {
|
|
24
|
+
'^.+\\.(ts|tsx)$': [
|
|
25
|
+
'ts-jest',
|
|
26
|
+
{
|
|
27
|
+
// Transpile-only: the package is consumed by brands through SWC and is
|
|
28
|
+
// never type-checked at build time, so tests assert runtime behavior.
|
|
29
|
+
diagnostics: false,
|
|
30
|
+
tsconfig: {
|
|
31
|
+
isolatedModules: true,
|
|
32
|
+
jsx: 'react-jsx',
|
|
33
|
+
esModuleInterop: true,
|
|
34
|
+
allowSyntheticDefaultImports: true,
|
|
35
|
+
module: 'commonjs',
|
|
36
|
+
target: 'es2020',
|
|
37
|
+
lib: ['es2020', 'dom', 'dom.iterable'],
|
|
38
|
+
moduleResolution: 'node',
|
|
39
|
+
resolveJsonModule: true,
|
|
40
|
+
skipLibCheck: true
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
transformIgnorePatterns: ['node_modules/(?!(@akinon)/)']
|
|
46
|
+
};
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akinon/pz-masterpass-rest",
|
|
3
|
-
"version": "2.0.56
|
|
3
|
+
"version": "2.0.56",
|
|
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",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "jest"
|
|
9
|
+
},
|
|
7
10
|
"keywords": [
|
|
8
11
|
"masterpass",
|
|
9
12
|
"payment",
|
|
@@ -0,0 +1,189 @@
|
|
|
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
|
+
import { getPaymentPending, setPaymentPending } from '../utils/payment-utils';
|
|
8
|
+
|
|
9
|
+
const handleVerificationMock = jest.fn();
|
|
10
|
+
const accountAccessMock = jest.fn();
|
|
11
|
+
|
|
12
|
+
jest.mock('../utils/response-handler', () => ({
|
|
13
|
+
handleVerification: (...args: unknown[]) => handleVerificationMock(...args),
|
|
14
|
+
handleMasterpassResponse: jest.fn(async (response) => response)
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
jest.mock('../services/account', () => ({
|
|
18
|
+
AccountService: class {
|
|
19
|
+
accountAccess = accountAccessMock;
|
|
20
|
+
linkToMerchant = jest.fn();
|
|
21
|
+
removeCard = jest.fn();
|
|
22
|
+
addCard = jest.fn();
|
|
23
|
+
}
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
jest.mock('./useMasterpassToken', () => ({
|
|
27
|
+
useMasterpassToken: () => ({
|
|
28
|
+
refetch: jest.fn(async () => ({ data: { token: 'fresh-token' } }))
|
|
29
|
+
})
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
// Imported after the mocks so the hook picks them up.
|
|
33
|
+
import { useMasterpassAccount } from './useMasterpassAccount';
|
|
34
|
+
|
|
35
|
+
const renderAccountHook = (otpContext: 'account' | 'payment') => {
|
|
36
|
+
const store = configureStore({
|
|
37
|
+
reducer: { masterpassRest: masterpassRestReducer },
|
|
38
|
+
middleware: (getDefault) => getDefault({ serializableCheck: false })
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
store.dispatch({
|
|
42
|
+
type: 'masterpassRest/setMasterpassRestTokenData',
|
|
43
|
+
payload: {
|
|
44
|
+
MerchantId: 'merchant-1',
|
|
45
|
+
AccountKey: 'account-1',
|
|
46
|
+
UserId: 'user-1'
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
store.dispatch({
|
|
51
|
+
type: 'masterpassRest/updateModalState',
|
|
52
|
+
payload: { otpContext, showOTPModal: true }
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
56
|
+
<Provider store={store}>{children}</Provider>
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return { ...renderHook(() => useMasterpassAccount(), { wrapper }), store };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
accountAccessMock.mockResolvedValue({
|
|
64
|
+
statusCode: 200,
|
|
65
|
+
result: { cards: [] }
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('verification during a payment', () => {
|
|
70
|
+
it('hands the verification result back so the caller can complete the order', async () => {
|
|
71
|
+
handleVerificationMock.mockResolvedValue({
|
|
72
|
+
success: true,
|
|
73
|
+
data: { result: { responseCode: '0000', token: 'verified-token' } }
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const { result } = renderAccountHook('payment');
|
|
77
|
+
|
|
78
|
+
let outcome: any;
|
|
79
|
+
await act(async () => {
|
|
80
|
+
outcome = await result.current.handleOTPSubmit('123456');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(outcome).toEqual({
|
|
84
|
+
success: true,
|
|
85
|
+
isPaymentContext: true,
|
|
86
|
+
verificationData: { result: { responseCode: '0000', token: 'verified-token' } }
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('does not treat a refreshed card list as a completed payment', async () => {
|
|
91
|
+
handleVerificationMock.mockResolvedValue({
|
|
92
|
+
success: true,
|
|
93
|
+
data: { result: { responseCode: '0000', token: 'verified-token' } }
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const { result } = renderAccountHook('payment');
|
|
97
|
+
accountAccessMock.mockClear();
|
|
98
|
+
|
|
99
|
+
await act(async () => {
|
|
100
|
+
await result.current.handleOTPSubmit('123456');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Refreshing the account here used to be the only thing that happened, which
|
|
104
|
+
// left the order uncompleted on the backend.
|
|
105
|
+
expect(accountAccessMock).not.toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('keeps the order number pending when verification sends the shopper to 3D', async () => {
|
|
109
|
+
setPaymentPending('ORDER-1');
|
|
110
|
+
handleVerificationMock.mockResolvedValue({
|
|
111
|
+
requires3D: true,
|
|
112
|
+
redirectUrl: 'https://bank.example/3d'
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const location = window.location;
|
|
116
|
+
delete (window as any).location;
|
|
117
|
+
(window as any).location = { ...location, href: '' };
|
|
118
|
+
|
|
119
|
+
const { result } = renderAccountHook('payment');
|
|
120
|
+
|
|
121
|
+
await act(async () => {
|
|
122
|
+
await result.current.handleOTPSubmit('123456');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(getPaymentPending()?.orderNo).toBe('ORDER-1');
|
|
126
|
+
|
|
127
|
+
(window as any).location = location;
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('verification during an account operation', () => {
|
|
132
|
+
it('refreshes the saved cards and reports plain success', async () => {
|
|
133
|
+
handleVerificationMock.mockResolvedValue({
|
|
134
|
+
success: true,
|
|
135
|
+
data: { result: { responseCode: '0000' } }
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const { result } = renderAccountHook('account');
|
|
139
|
+
accountAccessMock.mockClear();
|
|
140
|
+
|
|
141
|
+
let outcome: any;
|
|
142
|
+
await act(async () => {
|
|
143
|
+
outcome = await result.current.handleOTPSubmit('123456');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(outcome).toEqual({ success: true });
|
|
147
|
+
expect(accountAccessMock).toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('leaves the pending record untouched on a 3D redirect', async () => {
|
|
151
|
+
setPaymentPending('ORDER-1');
|
|
152
|
+
const before = getPaymentPending()?.startedAt;
|
|
153
|
+
|
|
154
|
+
handleVerificationMock.mockResolvedValue({
|
|
155
|
+
requires3D: true,
|
|
156
|
+
redirectUrl: 'https://bank.example/3d'
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const location = window.location;
|
|
160
|
+
delete (window as any).location;
|
|
161
|
+
(window as any).location = { ...location, href: '' };
|
|
162
|
+
|
|
163
|
+
const { result } = renderAccountHook('account');
|
|
164
|
+
|
|
165
|
+
await act(async () => {
|
|
166
|
+
await result.current.handleOTPSubmit('123456');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(getPaymentPending()?.startedAt).toBe(before);
|
|
170
|
+
|
|
171
|
+
(window as any).location = location;
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('the default verification context', () => {
|
|
176
|
+
it('is the account flow, so an unset context never skips the card refresh', async () => {
|
|
177
|
+
handleVerificationMock.mockResolvedValue({
|
|
178
|
+
success: true,
|
|
179
|
+
data: { result: {} }
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const store = configureStore({
|
|
183
|
+
reducer: { masterpassRest: masterpassRestReducer },
|
|
184
|
+
middleware: (getDefault) => getDefault({ serializableCheck: false })
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(store.getState().masterpassRest.modalState.otpContext).toBe('account');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,255 @@
|
|
|
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
|
+
import { getPaymentPending, setPaymentPending } from '../utils/payment-utils';
|
|
8
|
+
|
|
9
|
+
const prepareMock = jest.fn();
|
|
10
|
+
const finalizeMock = jest.fn();
|
|
11
|
+
const binNumberMock = jest.fn();
|
|
12
|
+
const installmentMock = jest.fn();
|
|
13
|
+
const processPaymentMock = jest.fn();
|
|
14
|
+
const directPaymentMock = jest.fn();
|
|
15
|
+
const handleResponseMock = jest.fn();
|
|
16
|
+
|
|
17
|
+
const asMutation = (fn: jest.Mock) => () =>
|
|
18
|
+
[(arg: unknown) => ({ unwrap: () => fn(arg) }), { isLoading: false }];
|
|
19
|
+
|
|
20
|
+
jest.mock('../redux/api', () => ({
|
|
21
|
+
usePrepareMasterpassOrderMutation: asMutation(prepareMock),
|
|
22
|
+
useFinalizeMasterpassOrderMutation: asMutation(finalizeMock),
|
|
23
|
+
useSetMasterpassRestBinNumberMutation: asMutation(binNumberMock),
|
|
24
|
+
useCheckoutMasterpassInstallmentMutation: asMutation(installmentMock),
|
|
25
|
+
useQueryMasterpassRewardsMutation: asMutation(jest.fn()),
|
|
26
|
+
useSelectMasterpassRewardsMutation: asMutation(jest.fn())
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
jest.mock('../services/payment', () => ({
|
|
30
|
+
PaymentService: class {
|
|
31
|
+
processPayment = processPaymentMock;
|
|
32
|
+
directPayment = directPaymentMock;
|
|
33
|
+
}
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
jest.mock('../utils/response-handler', () => ({
|
|
37
|
+
handleMasterpassResponse: (...args: unknown[]) => handleResponseMock(...args)
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
jest.mock('./useMasterpassToken', () => ({
|
|
41
|
+
useMasterpassToken: () => ({
|
|
42
|
+
refetch: jest.fn(async () => ({ data: { token: 'fresh-token' } }))
|
|
43
|
+
})
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
// Imported after the mocks so the hook picks them up.
|
|
47
|
+
import { useMasterpassPayment } from './useMasterpassPayment';
|
|
48
|
+
|
|
49
|
+
const PREPARED_ORDER = {
|
|
50
|
+
pre_order: { total_amount_with_interest: '100.00' },
|
|
51
|
+
context_list: [{ page_context: { order_no: 'ORDER-1', extras: {} } }]
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const SELECTED_CARD = { cardAlias: 'my card', cardBin: '454671' };
|
|
55
|
+
const SELECTED_INSTALLMENT = { pk: 1, installment_count: 1 };
|
|
56
|
+
|
|
57
|
+
const NEW_CARD_FORM = {
|
|
58
|
+
cardNumber: '4546 7112 3456 7890',
|
|
59
|
+
cardholderName: 'Test User',
|
|
60
|
+
expiryDate: '12/30',
|
|
61
|
+
cvv: '123'
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const renderPaymentHook = ({ withNewCardForm = false } = {}) => {
|
|
65
|
+
const store = configureStore({
|
|
66
|
+
reducer: { masterpassRest: masterpassRestReducer },
|
|
67
|
+
middleware: (getDefault) => getDefault({ serializableCheck: false })
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
store.dispatch({
|
|
71
|
+
type: 'masterpassRest/updatePaymentState',
|
|
72
|
+
payload: {
|
|
73
|
+
selectedCard: SELECTED_CARD,
|
|
74
|
+
selectedInstallment: SELECTED_INSTALLMENT,
|
|
75
|
+
cvc: '123',
|
|
76
|
+
useThreeD: true
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (withNewCardForm) {
|
|
81
|
+
store.dispatch({
|
|
82
|
+
type: 'masterpassRest/setNewCardFormData',
|
|
83
|
+
payload: NEW_CARD_FORM
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
88
|
+
<Provider store={store}>{children}</Provider>
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return { ...renderHook(() => useMasterpassPayment(), { wrapper }), store };
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
prepareMock.mockResolvedValue(PREPARED_ORDER);
|
|
96
|
+
finalizeMock.mockResolvedValue({ errors: null });
|
|
97
|
+
processPaymentMock.mockResolvedValue({ success: true, data: { result: {} } });
|
|
98
|
+
directPaymentMock.mockResolvedValue({ success: true, data: { result: {} } });
|
|
99
|
+
handleResponseMock.mockResolvedValue({ success: true });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('order number lifecycle during payment', () => {
|
|
103
|
+
it('marks the order number pending as soon as it is bound to a transaction', async () => {
|
|
104
|
+
// The SDK call never resolves, so the assertion sees the state the user is
|
|
105
|
+
// in while the bank page is open.
|
|
106
|
+
processPaymentMock.mockImplementation(() => new Promise(() => {}));
|
|
107
|
+
|
|
108
|
+
const { result } = renderPaymentHook();
|
|
109
|
+
|
|
110
|
+
act(() => {
|
|
111
|
+
void result.current.processPayment();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await act(async () => {
|
|
115
|
+
await Promise.resolve();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(getPaymentPending()?.orderNo).toBe('ORDER-1');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('releases the order number once the backend has completed the order', async () => {
|
|
122
|
+
const { result } = renderPaymentHook();
|
|
123
|
+
|
|
124
|
+
await act(async () => {
|
|
125
|
+
await result.current.processPayment();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(finalizeMock).toHaveBeenCalled();
|
|
129
|
+
expect(getPaymentPending()).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('keeps the order number pending while the shopper is redirected to 3D', async () => {
|
|
133
|
+
handleResponseMock.mockResolvedValue({
|
|
134
|
+
requires3D: true,
|
|
135
|
+
redirectUrl: 'https://bank.example/3d'
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// jsdom refuses real navigation; the assignment itself is not under test.
|
|
139
|
+
const location = window.location;
|
|
140
|
+
delete (window as any).location;
|
|
141
|
+
(window as any).location = { ...location, href: '' };
|
|
142
|
+
|
|
143
|
+
const { result } = renderPaymentHook();
|
|
144
|
+
|
|
145
|
+
let outcome: any;
|
|
146
|
+
await act(async () => {
|
|
147
|
+
outcome = await result.current.processPayment();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(outcome.requiresRedirect).toBe(true);
|
|
151
|
+
expect(getPaymentPending()?.orderNo).toBe('ORDER-1');
|
|
152
|
+
|
|
153
|
+
(window as any).location = location;
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('keeps the order number pending while a verification step is open', async () => {
|
|
157
|
+
handleResponseMock.mockResolvedValue({
|
|
158
|
+
success: false,
|
|
159
|
+
requiresOTP: true,
|
|
160
|
+
otpType: 'OTP'
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const { result } = renderPaymentHook();
|
|
164
|
+
|
|
165
|
+
await act(async () => {
|
|
166
|
+
await result.current.processPayment();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(getPaymentPending()?.orderNo).toBe('ORDER-1');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('releases the order number when the payment is rejected outright', async () => {
|
|
173
|
+
handleResponseMock.mockResolvedValue({
|
|
174
|
+
success: false,
|
|
175
|
+
message: 'Insufficient funds'
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const { result } = renderPaymentHook();
|
|
179
|
+
|
|
180
|
+
await act(async () => {
|
|
181
|
+
await result.current.processPayment();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(getPaymentPending()).toBeNull();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('releases the order number when the flow throws', async () => {
|
|
188
|
+
prepareMock.mockRejectedValue(new Error('network down'));
|
|
189
|
+
setPaymentPending('STALE-ORDER');
|
|
190
|
+
|
|
191
|
+
const { result } = renderPaymentHook();
|
|
192
|
+
|
|
193
|
+
await act(async () => {
|
|
194
|
+
await result.current.processPayment();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect(getPaymentPending()).toBeNull();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('marks the order number pending on the new-card flow too', async () => {
|
|
201
|
+
directPaymentMock.mockImplementation(() => new Promise(() => {}));
|
|
202
|
+
|
|
203
|
+
const { result } = renderPaymentHook({ withNewCardForm: true });
|
|
204
|
+
|
|
205
|
+
act(() => {
|
|
206
|
+
void result.current.processDirectPayment();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await act(async () => {
|
|
210
|
+
await Promise.resolve();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(getPaymentPending()?.orderNo).toBe('ORDER-1');
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('completing a payment after a verification step', () => {
|
|
218
|
+
it('sends the verification result to the backend and releases the order number', async () => {
|
|
219
|
+
setPaymentPending('ORDER-1');
|
|
220
|
+
|
|
221
|
+
const { result } = renderPaymentHook();
|
|
222
|
+
|
|
223
|
+
let outcome: any;
|
|
224
|
+
await act(async () => {
|
|
225
|
+
outcome = await result.current.finalizePaymentAfterVerification({
|
|
226
|
+
result: { responseCode: '0000', token: 'verified-token' }
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(finalizeMock).toHaveBeenCalledWith({
|
|
231
|
+
responseCode: '0000',
|
|
232
|
+
token: 'verified-token',
|
|
233
|
+
three_d_secure: true,
|
|
234
|
+
transactionType: 'PURCHASE_3D'
|
|
235
|
+
});
|
|
236
|
+
expect(outcome.success).toBe(true);
|
|
237
|
+
expect(getPaymentPending()).toBeNull();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('surfaces backend errors instead of reporting success', async () => {
|
|
241
|
+
finalizeMock.mockResolvedValue({ errors: { non_field_errors: 'ORDER_NO_MISMATCH' } });
|
|
242
|
+
|
|
243
|
+
const { result } = renderPaymentHook();
|
|
244
|
+
|
|
245
|
+
let outcome: any;
|
|
246
|
+
await act(async () => {
|
|
247
|
+
outcome = await result.current.finalizePaymentAfterVerification({
|
|
248
|
+
result: { responseCode: '0000', token: 'verified-token' }
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
expect(outcome.success).toBe(false);
|
|
253
|
+
expect(outcome.message).toEqual({ non_field_errors: 'ORDER_NO_MISMATCH' });
|
|
254
|
+
});
|
|
255
|
+
});
|