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