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
15 changes: 15 additions & 0 deletions shared/lib/error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
isErrorWithMessage,
logErrorWithMessage,
createErrorFromNetworkRequest,
getErrorBodyMessage,
} from './error';

jest.mock('loglevel');
Expand Down Expand Up @@ -34,6 +35,20 @@ describe('error module', () => {
});
});

describe('getErrorBodyMessage', () => {
it('returns the message from an error body', () => {
expect(getErrorBodyMessage({ message: 'Bad request' })).toBe(
'Bad request',
);
});

it('returns undefined when the body has no message string', () => {
expect(getErrorBodyMessage({ message: 400 })).toBeUndefined();
expect(getErrorBodyMessage({ error: 'Bad request' })).toBeUndefined();
expect(getErrorBodyMessage(undefined)).toBeUndefined();
});
});

describe('createErrorFromNetworkRequest', () => {
it('parses JSON response with error field', async () => {
const response = {
Expand Down
9 changes: 9 additions & 0 deletions shared/lib/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ export function logErrorWithMessage(error: unknown) {
log.error(isErrorWithMessage(error) ? getErrorMessage(error) : error);
}

export function getErrorBodyMessage(body: unknown) {
if (!body || typeof body !== 'object' || !('message' in body)) {
return undefined;
}

const { message } = body as { message?: unknown };
return typeof message === 'string' ? message : undefined;
}

export enum OAuthErrorMessages {
// Error message for Authentication Server when failed to get the auth token
// TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860
Expand Down
104 changes: 103 additions & 1 deletion ui/components/multichain/activity-v2/useTransactionsQuery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
act,
renderHook as renderHookBase,
} from '@testing-library/react-hooks';
import { HttpError } from '@metamask/core-backend';
import {
usePrefetchTransactions,
useTransactionsQuery,
Expand All @@ -31,6 +32,13 @@ jest.mock('../../../helpers/api-client', () => ({
const selectedAddress = '0x4f5243ceea96cee1da0fdb89c756d0e999439424';
const expectedEvmAddress = selectedAddress;
const expectedNetworks = ['eip155:1'];
const emptyResponse = {
data: [],
pageInfo: {
count: 0,
hasNextPage: false,
},
};
const mockStore = configureMockStore()({
localeMessages: {
currentLocale: 'en_GB',
Expand Down Expand Up @@ -82,6 +90,54 @@ afterEach(() => {
});

describe('useTransactionsQuery', () => {
it('normalizes unsupported network api errors to an empty response', async () => {
const unsupportedNetworksError = new HttpError(
'http 400: Bad Request',
400,
'Bad Request',
'https://accounts.api.cx.metamask.io/v4/multiaccount/transactions',
{
statusCode: 400,
message:
'networks param contains no supported chains: eip155:43114, eip155:324',
},
);
mockGetV4MultiAccountTransactionsInfiniteQueryOptions.mockReturnValue({
queryKey: ['transactions'],
queryFn: jest.fn().mockRejectedValue(unsupportedNetworksError),
getNextPageParam: jest.fn(),
enabled: true,
});

renderQueryHook(() => useTransactionsQuery());

const queryOptions = mockUseInfiniteQuery.mock.calls[0][0];
await expect(queryOptions.queryFn({})).resolves.toStrictEqual(
emptyResponse,
);
});

it('keeps unexpected api errors as query errors', async () => {
const unexpectedError = new HttpError(
'http 500: Internal Server Error',
500,
'Internal Server Error',
'https://accounts.api.cx.metamask.io/v4/multiaccount/transactions',
{ statusCode: 500, message: 'Unexpected error' },
);
mockGetV4MultiAccountTransactionsInfiniteQueryOptions.mockReturnValue({
queryKey: ['transactions'],
queryFn: jest.fn().mockRejectedValue(unexpectedError),
getNextPageParam: jest.fn(),
enabled: true,
});

renderQueryHook(() => useTransactionsQuery());

const queryOptions = mockUseInfiniteQuery.mock.calls[0][0];
await expect(queryOptions.queryFn({})).rejects.toBe(unexpectedError);
});

it('composes query options and delegates to useInfiniteQuery', () => {
renderQueryHook(() => useTransactionsQuery());

Expand Down Expand Up @@ -171,7 +227,53 @@ describe('usePrefetchTransactions', () => {
});

expect(mockQueryClient.prefetchInfiniteQuery).toHaveBeenCalledWith(
expect.objectContaining({ ...queryOptions, staleTime: 300000 }),
expect.objectContaining({
queryKey: queryOptions.queryKey,
queryFn: expect.any(Function),
getNextPageParam: queryOptions.getNextPageParam,
enabled: true,
retry: false,
staleTime: 300000,
}),
);
});

it('prefetches unsupported networks as an empty response', async () => {
const unsupportedNetworksError = new HttpError(
'http 400: Bad Request',
400,
'Bad Request',
'https://accounts.api.cx.metamask.io/v4/multiaccount/transactions',
{
statusCode: 400,
message: 'networks param contains no supported chains: eip155:43114',
},
);
const mockQueryClient = {
getQueryData: jest.fn().mockReturnValue(undefined),
isFetching: jest.fn().mockReturnValue(0),
prefetchInfiniteQuery: jest.fn(({ queryFn }) => queryFn({})),
};
const queryOptions = {
queryKey: ['transactions'],
queryFn: jest.fn().mockRejectedValue(unsupportedNetworksError),
getNextPageParam: jest.fn(),
enabled: true,
};

mockUseQueryClient.mockReturnValue(mockQueryClient);
mockGetV4MultiAccountTransactionsInfiniteQueryOptions.mockReturnValue(
queryOptions,
);

const { result } = renderQueryHook(() => usePrefetchTransactions());

act(() => {
result.current();
});

await expect(
mockQueryClient.prefetchInfiniteQuery.mock.results[0].value,
).resolves.toStrictEqual(emptyResponse);
});
});
54 changes: 52 additions & 2 deletions ui/components/multichain/activity-v2/useTransactionsQuery.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useCallback, useMemo } from 'react';
import { useSelector } from 'react-redux';
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { HttpError } from '@metamask/core-backend';
import type { CaipChainId } from '@metamask/utils';
import { getErrorBodyMessage } from '../../../../shared/lib/error';
import { selectTransactions } from '../../../../shared/lib/multichain/transformations';
import { MINUTE } from '../../../../shared/constants/time';
import { getUseExternalServices } from '../../../selectors';
Expand All @@ -12,6 +14,52 @@ import { selectEnabledNetworksAsCaipChainIds } from '../../../selectors/multicha
import { selectRequiredTransactionHashes } from '../../../selectors/transactionController';
import type { ActivityListFilter } from './helpers';

const knownApiMessages = ['networks param contains no supported chains'];

type TransactionsQueryOptions = ReturnType<
typeof apiClient.accounts.getV4MultiAccountTransactionsInfiniteQueryOptions
>;
type TransactionsQueryFunction = Extract<
NonNullable<TransactionsQueryOptions['queryFn']>,
(...args: never[]) => unknown
>;

function isKnownApiResponseError(error: unknown) {
if (!(error instanceof HttpError) || error.status !== 400) {
return false;
}

const errorBodyMessage = getErrorBodyMessage(error.body);

return Boolean(
errorBodyMessage &&
knownApiMessages.some((message) => errorBodyMessage.includes(message)),
);
}

function withKnownApiResponse(queryFn: TransactionsQueryOptions['queryFn']) {
if (typeof queryFn !== 'function') {
return queryFn;
}

return async (context: Parameters<TransactionsQueryFunction>[0]) => {
try {
return await queryFn(context);
} catch (error) {
if (isKnownApiResponseError(error)) {
return {
data: [],
pageInfo: {
count: 0,
hasNextPage: false,
},
};
}
throw error;
}
};
}

function getTransactionApiLanguage(locale: string) {
return locale.split('-')[0];
}
Expand Down Expand Up @@ -68,9 +116,10 @@ export function useTransactionsQuery(filter?: ActivityListFilter) {
lang,
});

// @ts-expect-error apiClient returns v5 types, repo still in v4
return useInfiniteQuery({
...queryOptions,
// @ts-expect-error apiClient returns v5 types, repo still in v4
queryFn: withKnownApiResponse(queryOptions.queryFn),
select: selectFn,
enabled:
Boolean(useExternalServices) &&
Expand Down Expand Up @@ -116,9 +165,10 @@ export function usePrefetchTransactions() {
}

queryClient
// @ts-expect-error apiClient returns v5 types, repo still in v4
.prefetchInfiniteQuery({
...queryOptions,
// @ts-expect-error apiClient returns v5 types, repo still in v4
queryFn: withKnownApiResponse(queryOptions.queryFn),
retry: false,
staleTime: 5 * MINUTE,
})
Expand Down
Loading