import React from 'react'; import { renderHook, act } from '@testing-library/react'; import { Provider } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; import masterpassRestReducer from '../redux/reducer'; import { getPaymentPending, setPaymentPending } from '../utils/payment-utils'; const prepareMock = jest.fn(); const finalizeMock = jest.fn(); const binNumberMock = jest.fn(); const installmentMock = jest.fn(); const processPaymentMock = jest.fn(); const directPaymentMock = jest.fn(); const handleResponseMock = jest.fn(); const asMutation = (fn: jest.Mock) => () => [(arg: unknown) => ({ unwrap: () => fn(arg) }), { isLoading: false }]; jest.mock('../redux/api', () => ({ usePrepareMasterpassOrderMutation: asMutation(prepareMock), useFinalizeMasterpassOrderMutation: asMutation(finalizeMock), useSetMasterpassRestBinNumberMutation: asMutation(binNumberMock), useCheckoutMasterpassInstallmentMutation: asMutation(installmentMock), useQueryMasterpassRewardsMutation: asMutation(jest.fn()), useSelectMasterpassRewardsMutation: asMutation(jest.fn()) })); jest.mock('../services/payment', () => ({ PaymentService: class { processPayment = processPaymentMock; directPayment = directPaymentMock; } })); jest.mock('../utils/response-handler', () => ({ handleMasterpassResponse: (...args: unknown[]) => handleResponseMock(...args) })); jest.mock('./useMasterpassToken', () => ({ useMasterpassToken: () => ({ refetch: jest.fn(async () => ({ data: { token: 'fresh-token' } })) }) })); // Imported after the mocks so the hook picks them up. import { useMasterpassPayment } from './useMasterpassPayment'; const PREPARED_ORDER = { pre_order: { total_amount_with_interest: '100.00' }, context_list: [{ page_context: { order_no: 'ORDER-1', extras: {} } }] }; const SELECTED_CARD = { cardAlias: 'my card', cardBin: '454671' }; const SELECTED_INSTALLMENT = { pk: 1, installment_count: 1 }; const NEW_CARD_FORM = { cardNumber: '4546 7112 3456 7890', cardholderName: 'Test User', expiryDate: '12/30', cvv: '123' }; const renderPaymentHook = ({ withNewCardForm = false } = {}) => { const store = configureStore({ reducer: { masterpassRest: masterpassRestReducer }, middleware: (getDefault) => getDefault({ serializableCheck: false }) }); store.dispatch({ type: 'masterpassRest/updatePaymentState', payload: { selectedCard: SELECTED_CARD, selectedInstallment: SELECTED_INSTALLMENT, cvc: '123', useThreeD: true } }); if (withNewCardForm) { store.dispatch({ type: 'masterpassRest/setNewCardFormData', payload: NEW_CARD_FORM }); } const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); return { ...renderHook(() => useMasterpassPayment(), { wrapper }), store }; }; beforeEach(() => { prepareMock.mockResolvedValue(PREPARED_ORDER); finalizeMock.mockResolvedValue({ errors: null }); processPaymentMock.mockResolvedValue({ success: true, data: { result: {} } }); directPaymentMock.mockResolvedValue({ success: true, data: { result: {} } }); handleResponseMock.mockResolvedValue({ success: true }); }); describe('order number lifecycle during payment', () => { it('marks the order number pending as soon as it is bound to a transaction', async () => { // The SDK call never resolves, so the assertion sees the state the user is // in while the bank page is open. processPaymentMock.mockImplementation(() => new Promise(() => {})); const { result } = renderPaymentHook(); act(() => { void result.current.processPayment(); }); await act(async () => { await Promise.resolve(); }); expect(getPaymentPending()?.orderNo).toBe('ORDER-1'); }); it('releases the order number once the backend has completed the order', async () => { const { result } = renderPaymentHook(); await act(async () => { await result.current.processPayment(); }); expect(finalizeMock).toHaveBeenCalled(); expect(getPaymentPending()).toBeNull(); }); it('keeps the order number pending while the shopper is redirected to 3D', async () => { handleResponseMock.mockResolvedValue({ requires3D: true, redirectUrl: 'https://bank.example/3d' }); // jsdom refuses real navigation; the assignment itself is not under test. const location = window.location; delete (window as any).location; (window as any).location = { ...location, href: '' }; const { result } = renderPaymentHook(); let outcome: any; await act(async () => { outcome = await result.current.processPayment(); }); expect(outcome.requiresRedirect).toBe(true); expect(getPaymentPending()?.orderNo).toBe('ORDER-1'); (window as any).location = location; }); it('keeps the order number pending while a verification step is open', async () => { handleResponseMock.mockResolvedValue({ success: false, requiresOTP: true, otpType: 'OTP' }); const { result } = renderPaymentHook(); await act(async () => { await result.current.processPayment(); }); expect(getPaymentPending()?.orderNo).toBe('ORDER-1'); }); it('releases the order number when the payment is rejected outright', async () => { handleResponseMock.mockResolvedValue({ success: false, message: 'Insufficient funds' }); const { result } = renderPaymentHook(); await act(async () => { await result.current.processPayment(); }); expect(getPaymentPending()).toBeNull(); }); it('releases the order number when the flow throws', async () => { prepareMock.mockRejectedValue(new Error('network down')); setPaymentPending('STALE-ORDER'); const { result } = renderPaymentHook(); await act(async () => { await result.current.processPayment(); }); expect(getPaymentPending()).toBeNull(); }); it('marks the order number pending on the new-card flow too', async () => { directPaymentMock.mockImplementation(() => new Promise(() => {})); const { result } = renderPaymentHook({ withNewCardForm: true }); act(() => { void result.current.processDirectPayment(); }); await act(async () => { await Promise.resolve(); }); expect(getPaymentPending()?.orderNo).toBe('ORDER-1'); }); }); describe('completing a payment after a verification step', () => { it('sends the verification result to the backend and releases the order number', async () => { setPaymentPending('ORDER-1'); const { result } = renderPaymentHook(); let outcome: any; await act(async () => { outcome = await result.current.finalizePaymentAfterVerification({ result: { responseCode: '0000', token: 'verified-token' } }); }); expect(finalizeMock).toHaveBeenCalledWith({ responseCode: '0000', token: 'verified-token', three_d_secure: true, transactionType: 'PURCHASE_3D' }); expect(outcome.success).toBe(true); expect(getPaymentPending()).toBeNull(); }); it('surfaces backend errors instead of reporting success', async () => { finalizeMock.mockResolvedValue({ errors: { non_field_errors: 'ORDER_NO_MISMATCH' } }); const { result } = renderPaymentHook(); let outcome: any; await act(async () => { outcome = await result.current.finalizePaymentAfterVerification({ result: { responseCode: '0000', token: 'verified-token' } }); }); expect(outcome.success).toBe(false); expect(outcome.message).toEqual({ non_field_errors: 'ORDER_NO_MISMATCH' }); }); });