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 type { MasterpassRestOptionRenderProps } from '../types/custom-render.types'; /** * Regression cover for APS-4980. * * Reported request sequence, captured from a shopper who hit ORDER_NO_MISMATCH: * * MasterpassRestBinNumberPage * MasterpassRestInstallmentPage * MasterpassRestOrderNoPage <- order number the SDK transaction is bound to * MasterpassRestBinNumberPage <- backend regenerates the order number * MasterpassRestInstallmentPage <- backend regenerates it again * MasterpassRestCompletePage <- arrives with the order number from step 3 * * Steps 4 and 5 are not shopper actions: they are the theme preselecting the * first saved card and the first installment when checkout mounts again after * the shopper leaves for 3D and comes back. These tests pin the request * sequence so that regression cannot return unnoticed. */ let pages: string[] = []; let orderNoCounter = 0; let currentOrderNo = ''; const recordPage = (page: string) => { pages.push(page); }; // Mirrors the backend: every bin/installment call re-derives the order number. const regenerateOrderNo = () => { orderNoCounter += 1; currentOrderNo = `ORDER-${orderNoCounter}`; }; const completeCalls: Array<{ orderNoAtBackend: string }> = []; jest.mock('../redux/api', () => ({ useSetMasterpassRestBinNumberMutation: () => [ () => ({ unwrap: async () => { recordPage('MasterpassRestBinNumberPage'); regenerateOrderNo(); return { context_list: [ { page_context: { installments: [{ pk: 1, installment_count: 1, label: 'Tek Çekim' }], card_type: { name: 'visa', slug: 'visa', logo: '' } } } ] }; } }), { isLoading: false } ], useCheckoutMasterpassInstallmentMutation: () => [ () => ({ unwrap: async () => { recordPage('MasterpassRestInstallmentPage'); regenerateOrderNo(); return { context_list: [] }; } }), { isLoading: false } ], usePrepareMasterpassOrderMutation: () => [ () => ({ unwrap: async () => { recordPage('MasterpassRestOrderNoPage'); return { pre_order: { total_amount_with_interest: '100.00' }, context_list: [{ page_context: { order_no: currentOrderNo, extras: {} } }] }; } }), { isLoading: false } ], useFinalizeMasterpassOrderMutation: () => [ () => ({ unwrap: async () => { recordPage('MasterpassRestCompletePage'); completeCalls.push({ orderNoAtBackend: currentOrderNo }); return { errors: null }; } }), { isLoading: false } ], useQueryMasterpassRewardsMutation: () => [ () => ({ unwrap: async () => ({ context_list: [] }) }), { isLoading: false } ], useSelectMasterpassRewardsMutation: () => [ () => ({ unwrap: async () => ({}) }), { isLoading: false } ] })); // The order number the SDK was handed when the transaction opened. let orderNoHeldBySdk: string | null = null; let sdkOutcome: any = { success: true }; jest.mock('../services/payment', () => ({ PaymentService: class { async processPayment(request: any) { orderNoHeldBySdk = request.orderNo; return sdkOutcome; } async directPayment(request: any) { orderNoHeldBySdk = request.orderNo; return sdkOutcome; } } })); let responseHandlerResult: any = { success: true }; jest.mock('../utils/response-handler', () => ({ handleMasterpassResponse: async () => responseHandlerResult, handleVerification: async () => ({ success: true, data: { result: {} } }) })); jest.mock('../hooks/useMasterpassToken', () => ({ useMasterpassToken: () => ({ refetch: jest.fn(async () => ({ data: { token: 'fresh-token' } })) }) })); jest.mock('../hooks/useMasterpassScript', () => ({ useMasterpassScript: () => ({ isScriptLoaded: true }) })); jest.mock('@akinon/next/utils', () => ({ getPosError: () => null, buildClientRequestUrl: (url: string) => url })); const STORED_CARD = { cardAlias: 'my card', cardBin: '454671', uniqueCardNumber: 'unique-1', maskedCardNumber: '4546 **** **** 7890' }; const accountHookValue = { accountData: { result: { cards: [STORED_CARD] } }, updateModalState: jest.fn(), handleLinkConfirm: jest.fn(), handleOTPSubmit: jest.fn(async () => ({ success: true })), 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'; /** Reproduces the preselection a brand theme performs on mount. */ const ThemeWithAutoSelect = (props: MasterpassRestOptionRenderProps) => { const { isPaymentPending, handleCardSelect, handleInstallmentSelect, accountData, paymentState } = props; const firstCard = accountData?.result?.cards?.[0]; useEffect(() => { if (isPaymentPending || paymentState?.selectedCard || !firstCard) { return; } void handleCardSelect(firstCard); }, [isPaymentPending, paymentState?.selectedCard, firstCard, handleCardSelect]); useEffect(() => { if ( isPaymentPending || paymentState?.selectedInstallment || !paymentState?.installments?.length ) { return; } void handleInstallmentSelect(paymentState.installments[0]); }, [ isPaymentPending, paymentState?.installments, paymentState?.selectedInstallment, handleInstallmentSelect ]); return ( <> ); }; /** Each render is a fresh checkout mount, exactly like a page load. */ const mountCheckout = () => render( getDefault({ serializableCheck: false }) })} > }} /> ); const settle = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); }; const stubNavigation = () => { const location = window.location; delete (window as any).location; (window as any).location = { ...location, href: '' }; return () => { (window as any).location = location; }; }; beforeEach(() => { pages = []; orderNoCounter = 0; currentOrderNo = ''; completeCalls.length = 0; orderNoHeldBySdk = null; sdkOutcome = { success: true }; responseHandlerResult = { success: true }; }); describe('APS-4980 request sequence', () => { it('does not re-derive the order number when checkout remounts after a 3D redirect', async () => { responseHandlerResult = { requires3D: true, redirectUrl: 'https://bank.example/3d' }; const restoreNavigation = stubNavigation(); const first = mountCheckout(); await settle(); expect(pages).toEqual([ 'MasterpassRestBinNumberPage', 'MasterpassRestInstallmentPage' ]); await userEvent.click(screen.getByRole('button', { name: 'pay' })); await settle(); expect(pages).toEqual([ 'MasterpassRestBinNumberPage', 'MasterpassRestInstallmentPage', 'MasterpassRestOrderNoPage' ]); const orderNoBoundToTransaction = orderNoHeldBySdk; expect(orderNoBoundToTransaction).toBe(currentOrderNo); // The shopper leaves for the bank and comes back: unmount, then mount again. first.unmount(); mountCheckout(); await settle(); // The reported bug added two more pages here, moving the backend's order // number away from the one the open transaction carries. expect(pages).toEqual([ 'MasterpassRestBinNumberPage', 'MasterpassRestInstallmentPage', 'MasterpassRestOrderNoPage' ]); expect(currentOrderNo).toBe(orderNoBoundToTransaction); restoreNavigation(); }); it('reproduces the mismatch when the remount is left unguarded', async () => { // Same flow with the guard disabled, to show the test would have caught the // original defect rather than passing either way. responseHandlerResult = { requires3D: true, redirectUrl: 'https://bank.example/3d' }; const restoreNavigation = stubNavigation(); const UnguardedTheme = (props: MasterpassRestOptionRenderProps) => { const { handleCardSelect, accountData, paymentState } = props; const firstCard = accountData?.result?.cards?.[0]; useEffect(() => { if (paymentState?.selectedCard || !firstCard) return; void handleCardSelect(firstCard); }, [paymentState?.selectedCard, firstCard, handleCardSelect]); return ( ); }; const mountUnguarded = () => render( getDefault({ serializableCheck: false }) })} > }} /> ); const first = mountUnguarded(); await settle(); await userEvent.click(screen.getByRole('button', { name: 'pay' })); await settle(); const orderNoBoundToTransaction = orderNoHeldBySdk; first.unmount(); mountUnguarded(); await settle(); expect(pages).toEqual([ 'MasterpassRestBinNumberPage', 'MasterpassRestInstallmentPage', 'MasterpassRestOrderNoPage', 'MasterpassRestBinNumberPage', 'MasterpassRestInstallmentPage' ]); expect(currentOrderNo).not.toBe(orderNoBoundToTransaction); restoreNavigation(); }); it('completes against the same order number the transaction was opened with', async () => { mountCheckout(); await settle(); await userEvent.click(screen.getByRole('button', { name: 'pay' })); await settle(); expect(pages).toEqual([ 'MasterpassRestBinNumberPage', 'MasterpassRestInstallmentPage', 'MasterpassRestOrderNoPage', 'MasterpassRestCompletePage' ]); expect(completeCalls).toHaveLength(1); expect(completeCalls[0].orderNoAtBackend).toBe(orderNoHeldBySdk); }); it('leaves the shopper to pick a card before a new attempt can start', async () => { responseHandlerResult = { requires3D: true, redirectUrl: 'https://bank.example/3d' }; const restoreNavigation = stubNavigation(); const first = mountCheckout(); await settle(); await userEvent.click(screen.getByRole('button', { name: 'pay' })); await settle(); first.unmount(); mountCheckout(); await settle(); // Nothing is preselected, so paying straight away cannot open a second // transaction behind the one still in flight. await userEvent.click(screen.getByRole('button', { name: 'pay' })); await settle(); expect(pages.filter((page) => page === 'MasterpassRestOrderNoPage')).toHaveLength(1); restoreNavigation(); }); it('re-derives the order number once the shopper deliberately retries', async () => { responseHandlerResult = { requires3D: true, redirectUrl: 'https://bank.example/3d' }; const restoreNavigation = stubNavigation(); const first = mountCheckout(); await settle(); await userEvent.click(screen.getByRole('button', { name: 'pay' })); await settle(); first.unmount(); // Choosing a card again is an explicit new attempt, so a fresh order number // is the correct outcome here. responseHandlerResult = { success: true }; mountCheckout(); await settle(); await userEvent.click(screen.getByRole('button', { name: 'choose card' })); await settle(); await userEvent.click(screen.getByRole('button', { name: 'pay' })); await settle(); expect(pages.filter((page) => page === 'MasterpassRestOrderNoPage')).toHaveLength(2); expect(completeCalls[0].orderNoAtBackend).toBe(orderNoHeldBySdk); restoreNavigation(); }); });