import { fireEvent, render, waitFor, within } from '@testing-library/react-native' import { FetchMock } from 'jest-fetch-mock/types' import React from 'react' import { Provider } from 'react-redux' import { ReactTestInstance } from 'react-test-renderer' import { RootState } from 'src/redux/reducers' import { getDynamicConfigParams, getFeatureGate, getMultichainFeatures } from 'src/statsig' import { StatsigFeatureGates } from 'src/statsig/types' import { QueryResponse } from 'src/transactions/feed/queryHelper' import TransactionFeed from 'src/transactions/feed/TransactionFeed' import { NetworkId, StandbyTransaction, TokenTransaction, TokenTransactionTypeV2, TransactionStatus, } from 'src/transactions/types' import networkConfig from 'src/web3/networkConfig' import { createMockStore, RecursivePartial } from 'test/utils' import { mockApprovalTransaction, mockCusdAddress, mockCusdTokenId } from 'test/values' jest.mock('src/statsig') const mockTransaction = ( transactionHash: string, status = TransactionStatus.Complete ): TokenTransaction => { return { networkId: NetworkId['celo-alfajores'], address: '0xd68360cce1f1ff696d898f58f03e0f1252f2ea33', amount: { tokenId: mockCusdTokenId, tokenAddress: mockCusdAddress, value: '0.1', }, block: '8648978', fees: [], metadata: {}, timestamp: 1542306118, transactionHash, type: TokenTransactionTypeV2.Received, status, } } const STAND_BY_TRANSACTION_SUBTITLE_KEY = 'confirmingTransaction' const MOCK_STANDBY_TRANSACTION: StandbyTransaction = { context: { id: 'test' }, networkId: NetworkId['celo-alfajores'], type: TokenTransactionTypeV2.Sent, status: TransactionStatus.Pending, amount: { value: '0.5', tokenAddress: mockCusdAddress, tokenId: mockCusdTokenId, }, metadata: {}, timestamp: 1542300000, address: '0xd68360cce1f1ff696d898f58f03e0f1252f2ea33', } const END_CURSOR = 'YXJyYXljb25uZWN0aW9uOjk=' const MOCK_EMPTY_RESPONSE: QueryResponse = { data: { tokenTransactionsV3: { pageInfo: { startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', endCursor: END_CURSOR, hasNextPage: true, hasPreviousPage: false, }, transactions: [], }, }, } const MOCK_EMPTY_RESPONSE_NO_NEXT_PAGE: QueryResponse = { data: { tokenTransactionsV3: { pageInfo: { startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', endCursor: END_CURSOR, hasNextPage: false, hasPreviousPage: false, }, transactions: [], }, }, } const MOCK_RESPONSE: QueryResponse = { data: { tokenTransactionsV3: { pageInfo: { startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', endCursor: END_CURSOR, hasNextPage: true, hasPreviousPage: false, }, transactions: [ mockTransaction('0x544367eaf2b01622dd1c7b75a6b19bf278d72127aecfb2e5106424c40c268e8b2'), ], }, }, } const MOCK_RESPONSE_NO_NEXT_PAGE: QueryResponse = { data: { tokenTransactionsV3: { pageInfo: { startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', endCursor: END_CURSOR, hasNextPage: false, hasPreviousPage: false, }, transactions: [ mockTransaction('0x544367eaf2b01622dd1c7b75a6b19bf278d72127aecfb2e5106424c40c268e8b'), ], }, }, } const MOCK_RESPONSE_MANY_ITEMS: QueryResponse = { data: { tokenTransactionsV3: { pageInfo: { startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', endCursor: END_CURSOR, hasNextPage: true, hasPreviousPage: false, }, transactions: [...Array(10).keys()].map((id) => mockTransaction(id.toString())), }, }, } const MOCK_RESPONSE_FAILED_TRANSACTION: QueryResponse = { data: { tokenTransactionsV3: { pageInfo: { startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', endCursor: END_CURSOR, hasNextPage: false, hasPreviousPage: false, }, transactions: [ mockTransaction( '0x544367eaf2b01622dd1c7b75a6b19bf278d72127aecfb2e5106424c40c268e8b', TransactionStatus.Failed ), ], }, }, } describe('TransactionFeed', () => { const mockFetch = fetch as FetchMock beforeEach(() => { jest.clearAllMocks() jest.mocked(getMultichainFeatures).mockReturnValue({ showCico: [NetworkId['celo-alfajores']], showBalances: [NetworkId['celo-alfajores']], showTransfers: [NetworkId['celo-alfajores']], showApprovalTxsInHomefeed: [NetworkId['celo-alfajores']], }) jest.mocked(getDynamicConfigParams).mockReturnValue({ jumpstartContracts: { ['celo-alfajores']: { contractAddress: '0x7bf3fefe9881127553d23a8cd225a2c2442c438c' }, }, }) mockFetch.resetMocks() }) function renderScreen(storeOverrides: RecursivePartial = {}) { const store = createMockStore({ ...storeOverrides, }) const tree = render( ) return { store, ...tree, } } function getNumTransactionItems(sectionList: ReactTestInstance) { // data[0] is the first section in the section list - all mock transactions // are for the same section / date return sectionList.props.data[0].data.length } it('only renders approval txs from supported networks', async () => { mockFetch.mockResponse(JSON.stringify(MOCK_EMPTY_RESPONSE_NO_NEXT_PAGE)) const tree = renderScreen({ transactions: { transactionsByNetworkId: { [NetworkId['ethereum-sepolia']]: [mockApprovalTransaction], [NetworkId['celo-alfajores']]: [ { ...mockApprovalTransaction, networkId: NetworkId['celo-alfajores'], transactionHash: '0xfoo', }, ], }, standbyTransactions: [], }, }) await waitFor(() => expect(tree.getByTestId('TransactionList').props.data.length).toBe(1)) expect(tree.queryByTestId('NoActivity/loading')).toBeNull() expect(tree.queryByTestId('NoActivity/error')).toBeNull() expect(mockFetch).toHaveBeenCalledTimes(1) expect(tree.getAllByTestId(new RegExp('TokenApprovalFeedItem', 'i')).length).toBe(1) expect(tree.queryByTestId(`TokenApprovalFeedItem/0xfoo`)).not.toBeNull() }) it('renders correctly when there is a response', async () => { mockFetch.mockResponse(JSON.stringify(MOCK_RESPONSE_NO_NEXT_PAGE)) const tree = renderScreen({}) await waitFor(() => expect(tree.getByTestId('TransactionList').props.data.length).toBe(1)) expect(tree.queryByTestId('NoActivity/loading')).toBeNull() expect(tree.queryByTestId('NoActivity/error')).toBeNull() expect(mockFetch).toHaveBeenCalledTimes(1) expect(tree.getAllByTestId('TransferFeedItem').length).toBe(1) expect( within(tree.getByTestId('TransferFeedItem')).getByTestId('TransferFeedItem/title') ).toHaveTextContent( 'feedItemReceivedTitle, {"displayName":"feedItemAddress, {\\"address\\":\\"0xd683...ea33\\"}"}' ) }) it('renders correctly with completed standby transactions', async () => { mockFetch.mockResponse(JSON.stringify(MOCK_RESPONSE_NO_NEXT_PAGE)) const tree = renderScreen({ transactions: { standbyTransactions: [ { ...MOCK_STANDBY_TRANSACTION, status: TransactionStatus.Complete, transactionHash: '0x544367eaf2b01622dd1c7b75a6b19bf278d72127aecfb2e5106424c40c268e8bxx', block: '8888', fees: [], }, ], }, }) await waitFor(() => expect(tree.getAllByTestId('TransferFeedItem').length).toBe(2)) }) it("doesn't render transfers for tokens that we don't know about", async () => { mockFetch.mockResponse(JSON.stringify(MOCK_RESPONSE_NO_NEXT_PAGE)) const { getAllByTestId, getByTestId } = renderScreen({}) await waitFor(() => getByTestId('TransactionList')) const items = getAllByTestId('TransferFeedItem/title') expect(items.length).toBe(1) }) it('renders the cache if there is one', async () => { mockFetch.mockReject(new Error('Test error')) const { getByTestId, queryByTestId } = renderScreen({ transactions: { transactionsByNetworkId: { [networkConfig.defaultNetworkId]: MOCK_RESPONSE.data.tokenTransactionsV3.transactions, }, }, }) expect(queryByTestId('NoActivity/loading')).toBeNull() expect(queryByTestId('NoActivity/error')).toBeNull() expect(getByTestId('TransactionList')).not.toBeNull() }) it('renders correctly when there are confirmed transactions and stand by transactions', async () => { mockFetch.mockResponse(JSON.stringify(MOCK_RESPONSE_NO_NEXT_PAGE)) const tree = renderScreen({ transactions: { standbyTransactions: [MOCK_STANDBY_TRANSACTION], }, }) await waitFor(() => tree.getByTestId('TransactionList')) expect(tree.queryByTestId('NoActivity/loading')).toBeNull() expect(tree.queryByTestId('NoActivity/error')).toBeNull() const subtitles = tree.queryAllByTestId('TransferFeedItem/subtitle') const pendingSubtitles = subtitles.filter((node) => node.children.some((ch) => ch === STAND_BY_TRANSACTION_SUBTITLE_KEY) ) expect(pendingSubtitles.length).toBe(1) }) it('renders correct status for a complete transaction', async () => { mockFetch.mockResponse(JSON.stringify(MOCK_RESPONSE_NO_NEXT_PAGE)) const { getByTestId, getByText } = renderScreen({}) await waitFor(() => getByTestId('TransactionList')) expect(getByText('feedItemReceivedInfo, {"context":"noComment"}')).toBeTruthy() }) it('renders correct status for a failed transaction', async () => { mockFetch.mockResponse(JSON.stringify(MOCK_RESPONSE_FAILED_TRANSACTION)) const { getByTestId, getByText } = renderScreen({}) await waitFor(() => getByTestId('TransactionList')) expect(getByText('feedItemFailedTransaction')).toBeTruthy() }) it('tries to fetch 10 transactions, unless the end is reached', async () => { mockFetch.mockImplementation((url: any, request: any) => { const body: string = request.body let response = '' if (body.includes(END_CURSOR)) { response = JSON.stringify(MOCK_RESPONSE_NO_NEXT_PAGE) } else { response = JSON.stringify(MOCK_RESPONSE) } return Promise.resolve(new Response(response)) }) const tree = renderScreen({}) await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2)) expect(getNumTransactionItems(tree.getByTestId('TransactionList'))).toBe(2) }) it('tries to fetch 10 transactions, and stores empty pages', async () => { mockFetch.mockImplementation((url: any, request: any) => { const body: string = request.body let response = '' if (body.includes(END_CURSOR)) { response = JSON.stringify(MOCK_RESPONSE_NO_NEXT_PAGE) } else { response = JSON.stringify(MOCK_EMPTY_RESPONSE) } return Promise.resolve(new Response(response)) }) const tree = renderScreen({}) await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2)) expect(getNumTransactionItems(tree.getByTestId('TransactionList'))).toBe(1) }) it('fetches the next page by scrolling to the end of the list', async () => { mockFetch.mockImplementation((url: any, request: any) => { const body: string = request.body let response = '' if (body.includes(END_CURSOR)) { response = JSON.stringify(MOCK_RESPONSE_NO_NEXT_PAGE) } else { response = JSON.stringify(MOCK_RESPONSE_MANY_ITEMS) } return Promise.resolve(new Response(response)) }) const tree = renderScreen({}) await waitFor(() => tree.getByTestId('TransactionList')) expect(mockFetch).toHaveBeenCalledTimes(1) expect(getNumTransactionItems(tree.getByTestId('TransactionList'))).toBe(10) fireEvent(tree.getByTestId('TransactionList'), 'onEndReached') await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2)) expect(getNumTransactionItems(tree.getByTestId('TransactionList'))).toBe(11) }) it('fetches the next page automatically if there are no transactions returned and next page exists', async () => { let mockFetchCount = 0 mockFetch.mockImplementation(() => { let response = '' switch (mockFetchCount) { case 1: response = JSON.stringify(MOCK_EMPTY_RESPONSE) break case 2: response = JSON.stringify(MOCK_RESPONSE) break default: response = JSON.stringify(MOCK_RESPONSE_MANY_ITEMS) } mockFetchCount += 1 return Promise.resolve(new Response(response)) }) const tree = renderScreen({}) await waitFor(() => tree.getByTestId('TransactionList')) expect(mockFetch).toHaveBeenCalledTimes(1) expect(getNumTransactionItems(tree.getByTestId('TransactionList'))).toBe(10) fireEvent(tree.getByTestId('TransactionList'), 'onEndReached') await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(3)) expect(getNumTransactionItems(tree.getByTestId('TransactionList'))).toBe(11) }) it('renders GetStarted if transaction feed is empty', async () => { jest.mocked(getFeatureGate).mockImplementation((gate) => { if (gate === StatsigFeatureGates.SHOW_UK_COMPLIANT_VARIANT) { return false } throw new Error('Unexpected gate') }) const { getByTestId } = renderScreen({}) expect(getByTestId('GetStarted')).toBeDefined() }) it('renders NoActivity for UK compliance', () => { jest.mocked(getFeatureGate).mockImplementation((gate) => { if (gate === StatsigFeatureGates.SHOW_UK_COMPLIANT_VARIANT) { return true } throw new Error('Unexpected gate') }) const { getByTestId, getByText } = renderScreen({}) expect(getByTestId('NoActivity/loading')).toBeTruthy() expect(getByText('transactionFeed.noTransactions')).toBeTruthy() }) })