import React, { useEffect } from 'react';
import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import masterpassRestReducer from '../redux/reducer';
import { setPaymentPending, getPaymentPending } from '../utils/payment-utils';
import type { MasterpassRestOptionRenderProps } from '../types/custom-render.types';
const handleCardSelectMock = jest.fn();
const handleInstallmentSelectMock = jest.fn();
const processPaymentMock = jest.fn();
const processDirectPaymentMock = jest.fn();
const finalizeAfterVerificationMock = jest.fn();
const updateModalStateMock = jest.fn();
const handleOTPSubmitMock = jest.fn();
const getPosErrorMock = jest.fn(() => null as any);
jest.mock('@akinon/next/utils', () => ({
getPosError: () => getPosErrorMock(),
buildClientRequestUrl: (url: string) => url
}));
jest.mock('../redux/api', () => ({
useSetMasterpassRestBinNumberMutation: () => [
() => ({ unwrap: async () => ({ context_list: [] }) }),
{ isLoading: false }
]
}));
jest.mock('../hooks/useMasterpassScript', () => ({
useMasterpassScript: () => ({ isScriptLoaded: true })
}));
// The real hooks memoize everything they hand out. The mocks return one frozen
// object so identities stay stable across renders, otherwise the view's effects
// re-run on every render and mask the behavior under test.
const paymentHookValue = {
isCheckoutLoading: false,
isInstallmentLoading: false,
isPrepareLoading: false,
isFinalizeLoading: false,
isRewardsQueryLoading: false,
isRewardsSelectLoading: false,
payableAmount: '100.00',
updatePaymentState: jest.fn(),
handleCardSelect: handleCardSelectMock,
handleInstallmentSelect: handleInstallmentSelectMock,
processPayment: processPaymentMock,
processDirectPayment: processDirectPaymentMock,
finalizePaymentAfterVerification: finalizeAfterVerificationMock,
fetchRewardsForCard: jest.fn(),
openRewardModal: jest.fn(),
closeRewardModal: jest.fn(),
confirmRewards: jest.fn()
};
jest.mock('../hooks/useMasterpassPayment', () => ({
useMasterpassPayment: () => paymentHookValue
}));
const STORED_CARD = {
cardAlias: 'my card',
cardBin: '454671',
uniqueCardNumber: 'unique-1',
maskedCardNumber: '4546 **** **** 7890'
};
const accountHookValue = {
accountData: { result: { cards: [STORED_CARD] } },
updateModalState: updateModalStateMock,
handleLinkConfirm: jest.fn(),
handleOTPSubmit: handleOTPSubmitMock,
handleRemoveCard: jest.fn(),
confirmRemoveCard: jest.fn(),
handleAddCard: jest.fn(),
resetData: jest.fn()
};
jest.mock('../hooks/useMasterpassAccount', () => ({
useMasterpassAccount: () => accountHookValue
}));
// Imported after the mocks so the view picks them up.
import MasterpassRestOption from './masterpass-rest-option';
/**
* Stands in for a brand theme. Themes preselect the first saved card on mount,
* which is what silently regenerated the order number after a shopper came back
* from the bank. The guard the theme is expected to honour is `isPaymentPending`.
*/
const ThemeWithAutoSelect = (props: MasterpassRestOptionRenderProps) => {
const { isPaymentPending, handleCardSelect, accountData, paymentState } = props;
const firstCard = accountData?.result?.cards?.[0];
useEffect(() => {
if (isPaymentPending || paymentState?.selectedCard || !firstCard) {
return;
}
void handleCardSelect(firstCard);
}, [isPaymentPending, paymentState?.selectedCard, firstCard, handleCardSelect]);
return (
{String(isPaymentPending)}
);
};
const renderOption = () => {
const store = configureStore({
reducer: { masterpassRest: masterpassRestReducer },
middleware: (getDefault) => getDefault({ serializableCheck: false })
});
return render(
}}
/>
);
};
beforeEach(() => {
getPosErrorMock.mockReturnValue(null);
processPaymentMock.mockResolvedValue({ success: true });
handleOTPSubmitMock.mockResolvedValue({ success: true });
finalizeAfterVerificationMock.mockResolvedValue({ success: true });
});
describe('returning to checkout while a transaction is still open', () => {
it('suppresses the automatic card preselection that would change the order number', async () => {
setPaymentPending('ORDER-1');
renderOption();
expect(screen.getByTestId('pending')).toHaveTextContent('true');
expect(handleCardSelectMock).not.toHaveBeenCalled();
});
it('preselects a card normally when no transaction is open', async () => {
renderOption();
expect(screen.getByTestId('pending')).toHaveTextContent('false');
expect(handleCardSelectMock).toHaveBeenCalledWith(STORED_CARD);
});
it('still lets the shopper start over by choosing a card themselves', async () => {
setPaymentPending('ORDER-1');
renderOption();
expect(handleCardSelectMock).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole('button', { name: 'choose card' }));
expect(handleCardSelectMock).toHaveBeenCalledWith(STORED_CARD);
expect(getPaymentPending()).toBeNull();
expect(screen.getByTestId('pending')).toHaveTextContent('false');
});
it('releases the guard when the shopper switches to a new card', async () => {
setPaymentPending('ORDER-1');
renderOption();
await userEvent.click(screen.getByRole('button', { name: 'use another card' }));
expect(getPaymentPending()).toBeNull();
});
it('releases the guard when the completion request came back with an error', async () => {
setPaymentPending('ORDER-1');
getPosErrorMock.mockReturnValue({ non_field_errors: 'ORDER_NO_MISMATCH' });
renderOption();
expect(getPaymentPending()).toBeNull();
expect(screen.getByTestId('pending')).toHaveTextContent('false');
});
it('releases the guard once the record has aged out', async () => {
window.sessionStorage.setItem(
'masterpass_rest_payment_pending',
JSON.stringify({ orderNo: 'ORDER-1', startedAt: Date.now() - 31 * 60 * 1000 })
);
renderOption();
expect(screen.getByTestId('pending')).toHaveTextContent('false');
expect(handleCardSelectMock).toHaveBeenCalledWith(STORED_CARD);
});
});
describe('a payment that needs verification', () => {
it('marks the verification as belonging to the payment, not the account', async () => {
processPaymentMock.mockResolvedValue({
requiresOTP: true,
otpType: 'OTP'
});
renderOption();
await userEvent.click(screen.getByRole('button', { name: 'pay' }));
expect(updateModalStateMock).toHaveBeenCalledWith({
showOTPModal: true,
otpType: 'OTP',
otpContext: 'payment'
});
});
it('completes the order against the backend once verification succeeds', async () => {
const verificationData = { result: { responseCode: '0000', token: 'verified' } };
handleOTPSubmitMock.mockResolvedValue({
success: true,
isPaymentContext: true,
verificationData
});
renderOption();
await userEvent.click(screen.getByRole('button', { name: 'verify' }));
expect(finalizeAfterVerificationMock).toHaveBeenCalledWith(verificationData);
});
it('does not complete an order for an account-only verification', async () => {
handleOTPSubmitMock.mockResolvedValue({ success: true });
renderOption();
await userEvent.click(screen.getByRole('button', { name: 'verify' }));
expect(finalizeAfterVerificationMock).not.toHaveBeenCalled();
});
it('reports a failed completion instead of letting it pass silently', async () => {
handleOTPSubmitMock.mockResolvedValue({
success: true,
isPaymentContext: true,
verificationData: { result: {} }
});
finalizeAfterVerificationMock.mockResolvedValue({
success: false,
message: 'ORDER_NO_MISMATCH'
});
renderOption();
await userEvent.click(screen.getByRole('button', { name: 'verify' }));
// The error dispatch lands after the finalize promise settles.
await act(async () => {
await Promise.resolve();
});
expect(finalizeAfterVerificationMock).toHaveBeenCalled();
});
});