Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion app/components/UI/Bridge/hooks/useTokenSelection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ jest.mock('./useIsNetworkEnabled', () => ({
useIsNetworkEnabled: jest.fn(() => true),
}));

const mockAutoUpdateDestToken = jest.fn();
jest.mock('./useAutoUpdateDestToken', () => ({
useAutoUpdateDestToken: () => ({
autoUpdateDestToken: mockAutoUpdateDestToken,
}),
}));

import { useSelector } from 'react-redux';
import { useIsNetworkEnabled } from './useIsNetworkEnabled';
const mockUseSelector = useSelector as jest.Mock;
Expand Down Expand Up @@ -60,7 +67,7 @@ describe('useTokenSelection', () => {
.mockReturnValueOnce(mockDestAmount); // selectDestAmount
});

it('dispatches setSourceToken when selecting new source token', async () => {
it('dispatches setSourceToken and calls autoUpdateDestToken when selecting new source token', async () => {
const { result } = renderHook(() =>
useTokenSelection(TokenSelectorType.Source),
);
Expand All @@ -74,6 +81,7 @@ describe('useTokenSelection', () => {
});

expect(mockDispatch).toHaveBeenCalledWith(setSourceToken(newToken));
expect(mockAutoUpdateDestToken).toHaveBeenCalledWith(newToken);
expect(mockGoBack).toHaveBeenCalled();
});

Expand All @@ -88,6 +96,7 @@ describe('useTokenSelection', () => {

expect(mockHandleSwitchTokens).toHaveBeenCalledWith(mockDestAmount);
expect(mockHandleSwitchTokensInner).toHaveBeenCalled();
expect(mockAutoUpdateDestToken).not.toHaveBeenCalled();
expect(mockGoBack).toHaveBeenCalled();
});

Expand Down Expand Up @@ -193,6 +202,7 @@ describe('useTokenSelection', () => {
});

expect(mockDispatch).toHaveBeenCalledWith(setSourceToken(newToken));
expect(mockAutoUpdateDestToken).toHaveBeenCalledWith(newToken);
expect(mockGoBack).toHaveBeenCalled();
});

Expand Down Expand Up @@ -237,6 +247,7 @@ describe('useTokenSelection', () => {
setSourceToken(sameAddressToken),
);
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(mockAutoUpdateDestToken).toHaveBeenCalledWith(sameAddressToken);
expect(mockHandleSwitchTokens).not.toHaveBeenCalled();
});

Expand All @@ -260,6 +271,7 @@ describe('useTokenSelection', () => {

expect(mockDispatch).toHaveBeenCalledWith(setSourceToken(sameChainToken));
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(mockAutoUpdateDestToken).toHaveBeenCalledWith(sameChainToken);
expect(mockHandleSwitchTokens).not.toHaveBeenCalled();
});
});
Expand Down
6 changes: 6 additions & 0 deletions app/components/UI/Bridge/hooks/useTokenSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { BridgeToken, TokenSelectorType } from '../types';
import { useSwitchTokens } from './useSwitchTokens';
import { useIsNetworkEnabled } from './useIsNetworkEnabled';
import { useAutoUpdateDestToken } from './useAutoUpdateDestToken';

/**
* Hook to manage token selection logic for Bridge token selector
Expand All @@ -26,6 +27,7 @@ export const useTokenSelection = (type: TokenSelectorType) => {
const destAmount = useSelector(selectDestAmount);
const { handleSwitchTokens } = useSwitchTokens();
const isDestNetworkEnabled = useIsNetworkEnabled(destToken?.chainId);
const { autoUpdateDestToken } = useAutoUpdateDestToken();

const handleTokenPress = useCallback(
async (token: BridgeToken) => {
Expand Down Expand Up @@ -59,6 +61,9 @@ export const useTokenSelection = (type: TokenSelectorType) => {
dispatch(isSourcePicker ? setSourceToken(token) : setDestToken(token));
if (!isSourcePicker) {
dispatch(setIsDestTokenManuallySet(true));
} else {
// Auto-update dest token when source token changes
autoUpdateDestToken(token);
}
}

Expand All @@ -73,6 +78,7 @@ export const useTokenSelection = (type: TokenSelectorType) => {
navigation,
handleSwitchTokens,
isDestNetworkEnabled,
autoUpdateDestToken,
],
);

Expand Down
175 changes: 161 additions & 14 deletions app/components/UI/Earn/hooks/useMusdConversion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { useSelector } from 'react-redux';
import { TransactionType } from '@metamask/transaction-controller';
import { selectMusdConversionEducationSeen } from '../../../../reducers/user';
import { trace, TraceName, TraceOperation } from '../../../../util/trace';
import { RootState } from '../../../../reducers';
import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts';

const mockTrace = trace as jest.MockedFunction<typeof trace>;

Expand Down Expand Up @@ -83,21 +85,44 @@ describe('useMusdConversion', () => {
const setupUseSelectorMock = ({
selectedAccount = mockSelectedAccount,
hasSeenConversionEducationScreen = true,
pendingApprovals = {},
transactions = [],
}: {
selectedAccount?: typeof mockSelectedAccount | null;
hasSeenConversionEducationScreen?: boolean;
pendingApprovals?: Record<string, unknown>;
transactions?: {
id: string;
type?: TransactionType;
chainId?: Hex;
txParams?: { from?: string };
}[];
} = {}) => {
const mockAccountSelector = jest.fn(() => selectedAccount);
mockUseSelector.mockReset();
const mockState = {
engine: {
backgroundState: {
ApprovalController: {
pendingApprovals,
},
TransactionController: {
transactions,
},
},
},
} as unknown as RootState;

mockUseSelector.mockImplementation((selector) => {
if (selector === selectMusdConversionEducationSeen) {
return hasSeenConversionEducationScreen;
}

return mockAccountSelector;
});
if (selector === selectSelectedInternalAccountByScope) {
return () => selectedAccount;
}

return { mockAccountSelector };
return selector(mockState);
});
};

beforeEach(() => {
Expand Down Expand Up @@ -141,7 +166,9 @@ describe('useMusdConversion', () => {

const { result } = renderHook(() => useMusdConversion());

await result.current.initiateConversion(mockConfig);
await act(async () => {
await result.current.initiateConversion(mockConfig);
});

expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.EARN.ROOT, {
screen: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS,
Expand All @@ -155,6 +182,109 @@ describe('useMusdConversion', () => {
});
});

it('returns same transaction ID for concurrent initiations before approval exists', async () => {
setupUseSelectorMock();

mockNetworkController.findNetworkClientIdByChainId.mockReturnValue(
'mainnet',
);

let resolveAddTransaction!: (value: {
transactionMeta: { id: string };
}) => void;
const addTransactionPromise = new Promise<{
transactionMeta: { id: string };
}>((resolve) => {
resolveAddTransaction = resolve;
});
mockTransactionController.addTransaction.mockReturnValue(
addTransactionPromise,
);

const { result } = renderHook(() => useMusdConversion());

let transactionIds!: [string | void, string | void];
await act(async () => {
const firstCall = result.current.initiateConversion(mockConfig);
const secondCall = result.current.initiateConversion(mockConfig);

resolveAddTransaction({ transactionMeta: { id: 'tx-123' } });

transactionIds = await Promise.all([firstCall, secondCall]);
});

expect(transactionIds).toEqual(['tx-123', 'tx-123']);
expect(
mockNetworkController.findNetworkClientIdByChainId,
).toHaveBeenCalledTimes(1);
expect(mockTransactionController.addTransaction).toHaveBeenCalledTimes(1);
expect(mockNavigation.navigate).toHaveBeenCalledTimes(1);
});

it('returns existing pending musdConversion transaction ID for same account and chain', async () => {
setupUseSelectorMock({
pendingApprovals: {
'tx-existing': { id: 'tx-existing' },
},
transactions: [
{
id: 'tx-existing',
type: TransactionType.musdConversion,
chainId: '0x1',
txParams: { from: mockSelectedAccount.address },
},
],
});

const { result } = renderHook(() => useMusdConversion());

let transactionId!: string | void;
await act(async () => {
transactionId = await result.current.initiateConversion(mockConfig);
});

expect(transactionId).toBe('tx-existing');
expect(mockNavigation.navigate).toHaveBeenCalledTimes(1);
expect(
mockNetworkController.findNetworkClientIdByChainId,
).not.toHaveBeenCalled();
expect(mockTransactionController.addTransaction).not.toHaveBeenCalled();
});

it('returns existing pending musdConversion transaction ID with mixed-case chainId and from address', async () => {
setupUseSelectorMock({
pendingApprovals: {
'tx-existing': { id: 'tx-existing' },
},
transactions: [
{
id: 'tx-existing',
type: TransactionType.musdConversion,
chainId: '0x1',
txParams: {
from: mockSelectedAccount.address.toUpperCase() as unknown as string,
},
},
],
});

const { result } = renderHook(() => useMusdConversion());

let transactionId!: string | void;
await act(async () => {
transactionId = await result.current.initiateConversion({
preferredPaymentToken: {
...mockConfig.preferredPaymentToken,
chainId: '0x1' as Hex,
},
});
});

expect(transactionId).toBe('tx-existing');
expect(mockNavigation.navigate).toHaveBeenCalledTimes(1);
expect(mockTransactionController.addTransaction).not.toHaveBeenCalled();
});

it('creates transaction with correct data structure', async () => {
setupUseSelectorMock();

Expand All @@ -167,7 +297,9 @@ describe('useMusdConversion', () => {

const { result } = renderHook(() => useMusdConversion());

await result.current.initiateConversion(mockConfig);
await act(async () => {
await result.current.initiateConversion(mockConfig);
});

expect(mockTransactionController.addTransaction).toHaveBeenCalledWith(
{
Expand Down Expand Up @@ -242,7 +374,10 @@ describe('useMusdConversion', () => {

const { result } = renderHook(() => useMusdConversion());

const transactionId = await result.current.initiateConversion(mockConfig);
let transactionId!: string | void;
await act(async () => {
transactionId = await result.current.initiateConversion(mockConfig);
});

expect(transactionId).toBeUndefined();
expect(mockTransactionController.addTransaction).not.toHaveBeenCalled();
Expand Down Expand Up @@ -275,9 +410,12 @@ describe('useMusdConversion', () => {

const { result } = renderHook(() => useMusdConversion());

const transactionId = await result.current.initiateConversion({
...mockConfig,
skipEducationCheck: true,
let transactionId!: string | void;
await act(async () => {
transactionId = await result.current.initiateConversion({
...mockConfig,
skipEducationCheck: true,
});
});

expect(transactionId).toBe('tx-123');
Expand Down Expand Up @@ -334,7 +472,9 @@ describe('useMusdConversion', () => {
navigationStack: 'CustomStack',
};

await result.current.initiateConversion(configWithCustomStack);
await act(async () => {
await result.current.initiateConversion(configWithCustomStack);
});

expect(mockNavigation.navigate).toHaveBeenCalledWith('CustomStack', {
screen: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS,
Expand All @@ -360,7 +500,10 @@ describe('useMusdConversion', () => {

const { result } = renderHook(() => useMusdConversion());

const transactionId = await result.current.initiateConversion(mockConfig);
let transactionId!: string | void;
await act(async () => {
transactionId = await result.current.initiateConversion(mockConfig);
});

expect(transactionId).toBe('tx-123');
});
Expand All @@ -377,7 +520,9 @@ describe('useMusdConversion', () => {

const { result } = renderHook(() => useMusdConversion());

await result.current.initiateConversion(mockConfig);
await act(async () => {
await result.current.initiateConversion(mockConfig);
});

expect(mockTrace).toHaveBeenCalledWith({
name: TraceName.MusdConversionNavigation,
Expand Down Expand Up @@ -409,7 +554,9 @@ describe('useMusdConversion', () => {

const { result } = renderHook(() => useMusdConversion());

await result.current.initiateConversion(mockConfig);
await act(async () => {
await result.current.initiateConversion(mockConfig);
});

expect(callOrder).toEqual(['trace', 'navigate']);
});
Expand Down
Loading
Loading