From 9adebed0b2810f223e304b6fe0173c6d29db8c49 Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Fri, 11 Oct 2024 19:22:07 +0100 Subject: [PATCH] test: add test coverage for tokenDetectionController --- .../src/TokenDetectionController.test.ts | 248 ++++++++++++++++++ .../mocks/mock-get-balances.ts | 17 ++ .../multi-chain-accounts.test.ts | 5 +- .../multi-chain-accounts.ts | 2 +- 4 files changed, 269 insertions(+), 3 deletions(-) diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index 72af3018be..f25d5d8d24 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -33,6 +33,12 @@ import { buildInfuraNetworkConfiguration, } from '../../network-controller/tests/helpers'; import { formatAggregatorNames } from './assetsUtil'; +import * as MutliChainAccountsServiceModule from './multi-chain-accounts-service'; +import { + MOCK_GET_BALANCES_RESPONSE, + createMockGetBalancesResponse, +} from './multi-chain-accounts-service/mocks/mock-get-balances'; +import { MOCK_GET_SUPPORTED_NETWORKS_RESPONSE } from './multi-chain-accounts-service/mocks/mock-get-supported-networks'; import { TOKEN_END_POINT_API } from './token-service'; import type { AllowedActions, @@ -46,9 +52,11 @@ import { } from './TokenDetectionController'; import { getDefaultTokenListState, + type TokenListMap, type TokenListState, type TokenListToken, } from './TokenListController'; +import type { Token } from './TokenRatesController'; import type { TokensController, TokensControllerState, @@ -173,9 +181,25 @@ function buildTokenDetectionControllerMessenger( }); } +const mockMultiChainAccountsService = () => { + const mockFetchSupportedNetworks = jest + .spyOn(MutliChainAccountsServiceModule, 'fetchSupportedNetworks') + .mockResolvedValue(MOCK_GET_SUPPORTED_NETWORKS_RESPONSE.fullSupport); + const mockFetchMultiChainBalances = jest + .spyOn(MutliChainAccountsServiceModule, 'fetchMultiChainBalances') + .mockResolvedValue(MOCK_GET_BALANCES_RESPONSE); + + return { + mockFetchSupportedNetworks, + mockFetchMultiChainBalances, + }; +}; + describe('TokenDetectionController', () => { const defaultSelectedAccount = createMockInternalAccount(); + mockMultiChainAccountsService(); + beforeEach(async () => { nock(TOKEN_END_POINT_API) .get(getTokensPath(ChainId.mainnet)) @@ -2236,6 +2260,229 @@ describe('TokenDetectionController', () => { }, ); }); + + // Our tests setup that RPC will try using sampleTokenA, and API will use sampleTokenB + // So can indicate which flow (RPC or API) is used + const arrangeActTestDetectTokensWithAccountsAPI = async (props?: { + /** Overwrite the tokens cache inside Tokens Controller */ + overrideMockTokensCache?: (typeof sampleTokenA)[]; + mockMultiChainAPI?: ReturnType; + overrideMockTokenGetState?: Partial; + }) => { + const { + overrideMockTokensCache = [sampleTokenA, sampleTokenB], + mockMultiChainAPI, + overrideMockTokenGetState, + } = props ?? {}; + + // Arrange - RPC Tokens Flow - Uses sampleTokenA + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + + // Arrange - API Tokens Flow - Uses sampleTokenB + const { mockFetchSupportedNetworks, mockFetchMultiChainBalances } = + mockMultiChainAPI ?? mockMultiChainAccountsService(); + + if (!mockMultiChainAPI) { + mockFetchSupportedNetworks.mockResolvedValue([1]); + mockFetchMultiChainBalances.mockResolvedValue( + createMockGetBalancesResponse([sampleTokenB.address], 1), + ); + } + + // Arrange - Selected Account + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + + // Arrange / Act - withController setup + invoke detectTokens + const { callAction } = await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + useAccountsAPI: true, // USING ACCOUNTS API + }, + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ + controller, + mockTokenListGetState, + callActionSpy, + mockTokensGetState, + }) => { + const tokenCacheData: TokenListMap = {}; + overrideMockTokensCache.forEach( + (t) => + (tokenCacheData[t.address] = { + name: t.name, + symbol: t.symbol, + decimals: t.decimals, + address: t.address, + occurrences: 1, + aggregators: t.aggregators, + iconUrl: t.image, + }), + ); + + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0x1': { + timestamp: 0, + data: tokenCacheData, + }, + }, + }); + + if (overrideMockTokenGetState) { + mockTokensGetState({ + ...getDefaultTokensState(), + ...overrideMockTokenGetState, + }); + } + + // Act + await controller.detectTokens({ + networkClientId: NetworkType.mainnet, + selectedAddress: selectedAccount.address, + }); + + return { + callAction: callActionSpy, + }; + }, + ); + + const assertAddedTokens = (token: Token) => + expect(callAction).toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + [token], + { + chainId: ChainId.mainnet, + selectedAddress: selectedAccount.address, + }, + ); + + const assertTokensNeverAdded = () => + expect(callAction).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + + return { + assertAddedTokens, + assertTokensNeverAdded, + mockFetchMultiChainBalances, + mockGetBalancesInSingleCall, + rpcToken: sampleTokenA, + apiToken: sampleTokenB, + }; + }; + + it('should trigger and use Accounts API for detection', async () => { + const { + assertAddedTokens, + mockFetchMultiChainBalances, + apiToken, + mockGetBalancesInSingleCall, + } = await arrangeActTestDetectTokensWithAccountsAPI(); + + expect(mockFetchMultiChainBalances).toHaveBeenCalled(); + expect(mockGetBalancesInSingleCall).not.toHaveBeenCalled(); + assertAddedTokens(apiToken); + }); + + /** + * TODO - discuss if this is correct. + * + * If the Accounts API succeeds, but the tokens cannot be added (unable to create `Token` shape) + * Then should we add no tokens & then finish? + * + * If we want to, we could then do a pass with the RPC flow? But would it be necessary? + * - DEV - we can just add a simple check at the end of the API flow where if no tokens were added, then count it as a failure and perform the RPC flow? + */ + it('uses the Accounts API but does not add unknown tokens', async () => { + // API returns sampleTokenB + // As this is not a known token (in cache), then is not added + const { + assertTokensNeverAdded, + mockFetchMultiChainBalances, + mockGetBalancesInSingleCall, + } = await arrangeActTestDetectTokensWithAccountsAPI({ + overrideMockTokensCache: [sampleTokenA], + }); + + expect(mockFetchMultiChainBalances).toHaveBeenCalled(); + expect(mockGetBalancesInSingleCall).not.toHaveBeenCalled(); + assertTokensNeverAdded(); + }); + + it('fallbacks from using the Accounts API if fails', async () => { + // Test 1 - fetch supported networks fails + let mockAPI = mockMultiChainAccountsService(); + mockAPI.mockFetchSupportedNetworks.mockRejectedValue( + new Error('Mock Error'), + ); + let actResult = await arrangeActTestDetectTokensWithAccountsAPI({ + mockMultiChainAPI: mockAPI, + }); + + expect(actResult.mockFetchMultiChainBalances).not.toHaveBeenCalled(); // never called as could not fetch supported networks... + expect(actResult.mockGetBalancesInSingleCall).toHaveBeenCalled(); // ...so then RPC flow was initiated + actResult.assertAddedTokens(actResult.rpcToken); + + // Test 2 - fetch multi chain fails + mockAPI = mockMultiChainAccountsService(); + mockAPI.mockFetchMultiChainBalances.mockRejectedValue( + new Error('Mock Error'), + ); + actResult = await arrangeActTestDetectTokensWithAccountsAPI({ + mockMultiChainAPI: mockAPI, + }); + + expect(actResult.mockFetchMultiChainBalances).toHaveBeenCalled(); // API was called, but failed... + expect(actResult.mockGetBalancesInSingleCall).toHaveBeenCalled(); // ...so then RPC flow was initiated + actResult.assertAddedTokens(actResult.rpcToken); + }); + + /** + * TODO - discuss if this is correct. + * + * If the Accounts API succeeds, but the tokens cannot be added (token already added) + * Then should we add no tokens (if they are all added) & then finish? + * + * If we want to, we could then do a pass with the RPC flow? But would it be necessary? + * - DEV - we can just add a simple check at the end of the API flow where if no tokens were added, then count it as a failure and perform the RPC flow? + */ + it('uses the Accounts API but does not add tokens that are already added', async () => { + // Here we populate the token state with a token that exists in the tokenAPI. + // So the token retrieved from the API should not be added + const { assertTokensNeverAdded, mockFetchMultiChainBalances } = + await arrangeActTestDetectTokensWithAccountsAPI({ + overrideMockTokenGetState: { + allDetectedTokens: { + '0x1': { + '0x0000000000000000000000000000000000000001': [ + { + address: sampleTokenB.address, + name: sampleTokenB.name, + symbol: sampleTokenB.symbol, + decimals: sampleTokenB.decimals, + aggregators: sampleTokenB.aggregators, + }, + ], + }, + }, + }, + }); + + expect(mockFetchMultiChainBalances).toHaveBeenCalled(); + assertTokensNeverAdded(); + }); }); }); @@ -2415,6 +2662,7 @@ async function withController( getBalancesInSingleCall: jest.fn(), trackMetaMetricsEvent: jest.fn(), messenger: buildTokenDetectionControllerMessenger(controllerMessenger), + useAccountsAPI: false, ...options, }); try { diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/mocks/mock-get-balances.ts b/packages/assets-controllers/src/multi-chain-accounts-service/mocks/mock-get-balances.ts index c80a15dfb4..08b0b98a44 100644 --- a/packages/assets-controllers/src/multi-chain-accounts-service/mocks/mock-get-balances.ts +++ b/packages/assets-controllers/src/multi-chain-accounts-service/mocks/mock-get-balances.ts @@ -64,3 +64,20 @@ export const MOCK_GET_BALANCES_RESPONSE: GetBalancesResponse = { ], unprocessedNetworks: [], }; + +export const createMockGetBalancesResponse = ( + tokenAddrs: string[], + chainId: number, +): GetBalancesResponse => ({ + count: tokenAddrs.length, + balances: tokenAddrs.map((a) => ({ + object: 'token', + address: a, + name: 'Mock Token', + symbol: 'MOCK', + decimals: 18, + balance: '10.18', + chainId, + })), + unprocessedNetworks: [], +}); diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.test.ts b/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.test.ts index 8f3cbf7754..0545a5ef4a 100644 --- a/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.test.ts +++ b/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.test.ts @@ -3,6 +3,7 @@ import nock from 'nock'; import { MOCK_GET_BALANCES_RESPONSE } from './mocks/mock-get-balances'; import { MOCK_GET_SUPPORTED_NETWORKS_RESPONSE } from './mocks/mock-get-supported-networks'; import { + MULTICHAIN_ACCOUNTS_DOMAIN, fetchMultiChainBalances, fetchSupportedNetworks, } from './multi-chain-accounts'; @@ -11,7 +12,7 @@ const MOCK_ADDRESS = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; describe('fetchSupportedNetworks()', () => { const createMockAPI = () => - nock('https://accounts.api.cx.metamask.io').get('/v1/supportedNetworks'); + nock(MULTICHAIN_ACCOUNTS_DOMAIN).get('/v1/supportedNetworks'); it('should successfully return supported networks array', async () => { const mockAPI = createMockAPI().reply( @@ -38,7 +39,7 @@ describe('fetchSupportedNetworks()', () => { describe('fetchMultiChainBalances()', () => { const createMockAPI = () => - nock('https://accounts.api.cx.metamask.io').get( + nock(MULTICHAIN_ACCOUNTS_DOMAIN).get( `/v2/accounts/${MOCK_ADDRESS}/balances`, ); diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.ts b/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.ts index 16719e8e69..f40afd2809 100644 --- a/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.ts +++ b/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.ts @@ -6,7 +6,7 @@ import type { GetSupportedNetworksResponse, } from './types'; -const MULTICHAIN_ACCOUNTS_DOMAIN = 'https://accounts.api.cx.metamask.io'; +export const MULTICHAIN_ACCOUNTS_DOMAIN = 'https://accounts.api.cx.metamask.io'; const getBalancesUrl = ( address: string,