Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
336 changes: 217 additions & 119 deletions app/components/hooks/DisplayName/useERC20Tokens.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { act, waitFor } from '@testing-library/react-native';
import { CHAIN_IDS } from '@metamask/transaction-controller';
import { NameType } from '../../UI/Name/Name.types';
import { useERC20Tokens } from './useERC20Tokens';
Expand All @@ -6,137 +7,234 @@ import { renderHookWithProvider } from '../../../util/test/renderWithProvider';
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,
},
},
},
},
},
// CHAIN_IDS.MAINNET = '0x1' → decimal 1
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 mockFetch(response: unknown) {
return jest.spyOn(global, 'fetch').mockResolvedValue({
json: () => Promise.resolve(response),
ok: true,
} as Response);
}

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 undefined initially before fetch resolves', () => {
mockFetch(makeTokenResponse(makeAddress()));

const { result } = renderHook([
{
type: NameType.EthereumAddress,
value: makeAddress(),
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();
mockFetch(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 when preferContractSymbol is true', async () => {
const address = makeAddress();
mockFetch(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 after fetch resolves', async () => {
const address = makeAddress();
mockFetch(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 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 if type is not EthereumAddress', () => {
const { result } = renderHook([
{
type: 'alternateType' as NameType,
value: makeAddress(),
variation: CHAIN_ID_MOCK,
},
]);

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

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('normalizes addresses to lowercase', async () => {
const address = makeAddress();
mockFetch(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 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 undefined if fetch fails', async () => {
jest.spyOn(global, 'fetch').mockRejectedValue(new Error('Network error'));

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

// Give fetch time to fail, state should remain empty
await act(async () => {
await new Promise((r) => setTimeout(r, 50));
});

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

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('uses correct API URL with comma-separated assetIds', async () => {
const address = makeAddress();
const fetchMock = mockFetch(makeTokenResponse(address));

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

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

const calledUrl = fetchMock.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');
});

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);
// 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();
const fetchMock = mockFetch(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(fetchMock).toHaveBeenCalledTimes(1);
});

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

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

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

// Second mount should read from cache synchronously without fetching
fetchMock.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(fetchMock).not.toHaveBeenCalled();
});
});
Loading
Loading