Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
334 changes: 215 additions & 119 deletions app/components/hooks/DisplayName/useERC20Tokens.test.ts
Original file line number Diff line number Diff line change
@@ -1,142 +1,238 @@
import { act, waitFor } from '@testing-library/react-native';
import { CHAIN_IDS } from '@metamask/transaction-controller';
import { handleFetch } from '@metamask/controller-utils';
import { NameType } from '../../UI/Name/Name.types';
import { useERC20Tokens } from './useERC20Tokens';
import { renderHookWithProvider } from '../../../util/test/renderWithProvider';

jest.mock('@metamask/controller-utils', () => ({
...jest.requireActual('@metamask/controller-utils'),
handleFetch: jest.fn(),
}));

const mockHandleFetch = handleFetch as jest.Mock;

const TOKEN_NAME_MOCK = 'Test Token';
const TOKEN_SYMBOL_MOCK = 'TT';
const TOKEN_ICON_URL_MOCK = 'https://example.com/icon.png';
const TOKEN_ADDRESS_MOCK = '0x0439e60F02a8900a951603950d8D4527f400C3f1';
const UNKNOWN_ADDRESS_MOCK = '0xabc123';

const STATE_MOCK = {
engine: {
backgroundState: {
TokenListController: {
tokensChainsCache: {
[CHAIN_IDS.MAINNET]: {
data: {
[TOKEN_ADDRESS_MOCK.toLowerCase()]: {
name: TOKEN_NAME_MOCK,
symbol: TOKEN_SYMBOL_MOCK,
iconUrl: TOKEN_ICON_URL_MOCK,
},
},
},
},
},
const CHAIN_ID_MOCK = CHAIN_IDS.MAINNET;

// Each test gets a unique address to avoid module-level cache pollution.
let addressCounter = 0;
const makeAddress = () =>
`0x${(++addressCounter).toString().padStart(40, '0')}`;
const makeAssetId = (address: string) =>
`eip155:1/erc20:${address.toLowerCase()}`;

function makeTokenResponse(address: string) {
return [
{
assetId: makeAssetId(address),
name: TOKEN_NAME_MOCK,
symbol: TOKEN_SYMBOL_MOCK,
iconUrl: TOKEN_ICON_URL_MOCK,
},
},
};
];
}

function renderHook(requests: Parameters<typeof useERC20Tokens>[0]) {
return renderHookWithProvider(() => useERC20Tokens(requests), { state: {} });
}

describe('useERC20Tokens', () => {
it('returns undefined if no token found', () => {
const {
result: { current },
} = renderHookWithProvider(
() =>
useERC20Tokens([
{
type: NameType.EthereumAddress,
value: UNKNOWN_ADDRESS_MOCK,
variation: CHAIN_IDS.MAINNET,
},
]),
{ state: STATE_MOCK },
);

expect(current[0]?.name).toBe(undefined);
beforeEach(() => {
jest.clearAllMocks();
});

it('returns name if found', () => {
const {
result: { current },
} = renderHookWithProvider(
() =>
useERC20Tokens([
{
type: NameType.EthereumAddress,
value: TOKEN_ADDRESS_MOCK,
variation: CHAIN_IDS.MAINNET,
},
]),
{ state: STATE_MOCK },
);

expect(current[0]?.name).toBe(TOKEN_NAME_MOCK);
it('returns undefined initially before fetch resolves', () => {
const address = makeAddress();
mockHandleFetch.mockResolvedValue(makeTokenResponse(address));

const { result } = renderHook([
{
type: NameType.EthereumAddress,
value: address,
variation: CHAIN_ID_MOCK,
},
]);

expect(result.current[0]).toEqual({ name: undefined, image: undefined });
});
Comment thread
cursor[bot] marked this conversation as resolved.

it('returns name after fetch resolves', async () => {
const address = makeAddress();
mockHandleFetch.mockResolvedValue(makeTokenResponse(address));

const { result } = renderHook([
{
type: NameType.EthereumAddress,
value: address,
variation: CHAIN_ID_MOCK,
},
]);

await waitFor(() => {
expect(result.current[0]?.name).toBe(TOKEN_NAME_MOCK);
});
});

it('returns symbol if preferred', () => {
const {
result: { current },
} = renderHookWithProvider(
() =>
useERC20Tokens([
{
preferContractSymbol: true,
type: NameType.EthereumAddress,
value: TOKEN_ADDRESS_MOCK,
variation: CHAIN_IDS.MAINNET,
},
]),
{ state: STATE_MOCK },
);

expect(current[0]?.name).toBe(TOKEN_SYMBOL_MOCK);
it('returns symbol when preferContractSymbol is true', async () => {
const address = makeAddress();
mockHandleFetch.mockResolvedValue(makeTokenResponse(address));

const { result } = renderHook([
{
preferContractSymbol: true,
type: NameType.EthereumAddress,
value: address,
variation: CHAIN_ID_MOCK,
},
]);

await waitFor(() => {
expect(result.current[0]?.name).toBe(TOKEN_SYMBOL_MOCK);
});
});

it('returns image', () => {
const {
result: { current },
} = renderHookWithProvider(
() =>
useERC20Tokens([
{
preferContractSymbol: true,
type: NameType.EthereumAddress,
value: TOKEN_ADDRESS_MOCK,
variation: CHAIN_IDS.MAINNET,
},
]),
{ state: STATE_MOCK },
);

expect(current[0]?.image).toBe(TOKEN_ICON_URL_MOCK);
it('returns image after fetch resolves', async () => {
const address = makeAddress();
mockHandleFetch.mockResolvedValue(makeTokenResponse(address));

const { result } = renderHook([
{
type: NameType.EthereumAddress,
value: address,
variation: CHAIN_ID_MOCK,
},
]);

await waitFor(() => {
expect(result.current[0]?.image).toBe(TOKEN_ICON_URL_MOCK);
});
});

it('returns null if type is not address', () => {
const {
result: { current },
} = renderHookWithProvider(
() =>
useERC20Tokens([
{
type: 'alternateType' as NameType,
value: TOKEN_ADDRESS_MOCK,
variation: CHAIN_IDS.MAINNET,
},
]),
{ state: STATE_MOCK },
);

expect(current[0]?.name).toBeUndefined();
it('returns undefined if type is not EthereumAddress', () => {
const { result } = renderHook([
{
type: 'alternateType' as NameType,
value: makeAddress(),
variation: CHAIN_ID_MOCK,
},
]);

expect(result.current[0]).toBeUndefined();
});

it('normalizes addresses to lowercase', () => {
const {
result: { current },
} = renderHookWithProvider(
() =>
useERC20Tokens([
{
type: NameType.EthereumAddress,
value: TOKEN_ADDRESS_MOCK.toUpperCase(),
variation: CHAIN_IDS.MAINNET,
},
]),
{ state: STATE_MOCK },
);

expect(current[0]?.name).toBe(TOKEN_NAME_MOCK);
it('normalizes addresses to lowercase', async () => {
const address = makeAddress();
mockHandleFetch.mockResolvedValue(makeTokenResponse(address));

const { result } = renderHook([
{
type: NameType.EthereumAddress,
value: address.toUpperCase(),
variation: CHAIN_ID_MOCK,
},
]);

await waitFor(() => {
expect(result.current[0]?.name).toBe(TOKEN_NAME_MOCK);
});
});

it('returns undefined if fetch fails', async () => {
mockHandleFetch.mockRejectedValue(new Error('Network error'));

const { result } = renderHook([
{
type: NameType.EthereumAddress,
value: makeAddress(),
variation: CHAIN_ID_MOCK,
},
]);

await act(async () => {
await new Promise((r) => setTimeout(r, 50));
});

expect(result.current[0]).toEqual({ name: undefined, image: undefined });
});

it('uses correct API URL with comma-separated assetIds', async () => {
const address = makeAddress();
mockHandleFetch.mockResolvedValue(makeTokenResponse(address));

renderHook([
{
type: NameType.EthereumAddress,
value: address,
variation: CHAIN_ID_MOCK,
},
]);

await waitFor(() => {
expect(mockHandleFetch).toHaveBeenCalledTimes(1);
});

const calledUrl = mockHandleFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain('tokens.api.cx.metamask.io/v3/assets');
expect(calledUrl).toContain(encodeURIComponent(makeAssetId(address)));
expect(calledUrl).toContain('includeIconUrl=true');
});

// Note: this test produces one act() warning because each renderHook call creates
// an independent React tree. When the shared in-flight promise resolves, all three
// trees update simultaneously, but only the one observed by waitFor is wrapped in act.
// This is a known RNTL limitation when testing cross-hook module-level state sharing.
it('deduplicates concurrent requests for the same token', async () => {
const address = makeAddress();
mockHandleFetch.mockResolvedValue(makeTokenResponse(address));

const request = [
{
type: NameType.EthereumAddress,
value: address,
variation: CHAIN_ID_MOCK,
},
];

const { result: r1 } = renderHook(request);
const { result: r2 } = renderHook(request);
const { result: r3 } = renderHook(request);

await waitFor(() => {
expect(r1.current[0]?.name).toBe(TOKEN_NAME_MOCK);
expect(r2.current[0]?.name).toBe(TOKEN_NAME_MOCK);
expect(r3.current[0]?.name).toBe(TOKEN_NAME_MOCK);
});

expect(mockHandleFetch).toHaveBeenCalledTimes(1);
});

it('returns cached data synchronously on second mount without re-fetching', async () => {
const address = makeAddress();
mockHandleFetch.mockResolvedValue(makeTokenResponse(address));

const request = [
{
type: NameType.EthereumAddress,
value: address,
variation: CHAIN_ID_MOCK,
},
];

const { result: result1 } = renderHook(request);
await waitFor(() => {
expect(result1.current[0]?.name).toBe(TOKEN_NAME_MOCK);
});

mockHandleFetch.mockClear();
const { result: result2 } = renderHook(request);

expect(result2.current[0]?.name).toBe(TOKEN_NAME_MOCK);
await act(async () => {
await new Promise((r) => setTimeout(r, 50));
});
expect(mockHandleFetch).not.toHaveBeenCalled();
});
});
Loading
Loading