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(); }); });