import { CryptoType, FiatAccountSchema, FiatAccountType } from '@fiatconnect/fiatconnect-types' import { act, fireEvent, render, waitFor } from '@testing-library/react-native' import _ from 'lodash' import * as React from 'react' import { Provider } from 'react-redux' import FiatConnectQuote from 'src/fiatExchanges/quotes/FiatConnectQuote' import { CICOFlow } from 'src/fiatExchanges/types' import { FiatConnectQuoteSuccess } from 'src/fiatconnect' import FiatConnectReviewScreen from 'src/fiatconnect/ReviewScreen' import { FiatAccount, createFiatConnectTransfer, refetchQuote } from 'src/fiatconnect/slice' import { LocalCurrencyCode } from 'src/localCurrency/consts' import { getDefaultLocalCurrencyCode } from 'src/localCurrency/selectors' import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' import { NetworkId } from 'src/transactions/types' import { TransactionRequest } from 'src/viem/prepareTransactions' import { getSerializablePreparedTransaction } from 'src/viem/preparedTransactionSerialization' import { createMockStore, getMockStackScreenProps } from 'test/utils' import { mockAccount, mockCeloTokenId, mockCeurAddress, mockCeurTokenBalance, mockCeurTokenId, mockCusdAddress, mockCusdTokenBalance, mockCusdTokenId, mockFiatConnectQuotes, mockTokenBalances, } from 'test/values' import { Address, parseGwei } from 'viem' jest.mock('src/web3/networkConfig', () => { const originalModule = jest.requireActual('src/web3/networkConfig') return { ...originalModule, __esModule: true, default: { ...originalModule.default, defaultNetworkId: 'celo-alfajores', }, } }) jest.mock('src/localCurrency/selectors', () => { const originalModule = jest.requireActual('src/localCurrency/selectors') return { ...originalModule, getDefaultLocalCurrencyCode: jest.fn(), } }) const mockPrepareERC20TransferTransaction = jest.fn() jest.mock('src/viem/prepareTransactions', () => ({ ...jest.requireActual('src/viem/prepareTransactions'), prepareERC20TransferTransaction: async () => mockPrepareERC20TransferTransaction(), })) function getProps( flow: CICOFlow, withFee = false, cryptoType = CryptoType.cUSD, shouldRefetchQuote = false, quoteExpireMs = 0 ) { const quoteData = _.cloneDeep(mockFiatConnectQuotes[1]) as FiatConnectQuoteSuccess if (!withFee) { delete quoteData.quote.fee } if (quoteExpireMs) { quoteData.quote.guaranteedUntil = new Date(Date.now() + quoteExpireMs).toISOString() } quoteData.quote.cryptoType = cryptoType const normalizedQuote = new FiatConnectQuote({ quote: quoteData, fiatAccountType: FiatAccountType.BankAccount, flow: CICOFlow.CashOut, tokenId: cryptoType === CryptoType.cEUR ? mockCeurTokenId : mockCusdTokenId, }) const fiatAccount: FiatAccount = { fiatAccountId: '123', accountName: 'Chase (...2345)', institutionName: 'Chase', fiatAccountType: FiatAccountType.BankAccount, fiatAccountSchema: FiatAccountSchema.AccountNumber, providerId: normalizedQuote.getProviderId(), } return getMockStackScreenProps(Screens.FiatConnectReview, { flow, normalizedQuote, fiatAccount, shouldRefetchQuote, }) } const store = createMockStore({ tokens: { tokenBalances: { [mockCusdTokenId]: { ...mockTokenBalances[mockCusdTokenId], balance: '200', priceUsd: '1', }, [mockCeurTokenId]: { ...mockTokenBalances[mockCeurTokenId], balance: '100', priceUsd: '1.2', }, [mockCeloTokenId]: { ...mockTokenBalances[mockCeloTokenId], balance: '200', priceUsd: '5', }, }, }, }) describe('ReviewScreen', () => { beforeEach(() => { jest.mocked(getDefaultLocalCurrencyCode).mockReturnValue(LocalCurrencyCode.USD) }) describe('cashIn', () => { beforeEach(() => { jest.clearAllMocks() }) it('shows fiat amount, transaction details and payment method', async () => { const { queryByTestId, queryByText } = render( ) await waitFor(() => expect(queryByText('fiatConnectReviewScreen.bankFeeDisclaimer')).toBeFalsy() ) expect(queryByTestId('receive-amount')?.children).toEqual(['100.00', ' cEUR']) expect(queryByText('fiatConnectReviewScreen.transactionDetails')).toBeTruthy() expect(queryByText('fiatConnectReviewScreen.cashIn.transactionDetailsAmount')).toBeTruthy() expect(queryByTestId('txDetails-total/value')?.children).toEqual(['$', '100.00']) expect(queryByTestId('txDetails-converted/value')?.children).toEqual(['$', '99.15']) expect(queryByTestId('txDetails-fee/value')?.children).toEqual(['$', '0.84']) expect(queryByTestId('txDetails-exchangeRate/value')?.children).toEqual(['$', '0.9915']) expect(queryByTestId('txDetails-receive')?.children).toEqual(['100.00', ' cEUR']) expect(queryByText('fiatConnectReviewScreen.cashIn.paymentMethodHeader')).toBeTruthy() expect(queryByTestId('paymentMethod-text')?.children).toEqual(['Chase (...2345)']) expect(queryByTestId('paymentMethod-via')?.children).toEqual([ 'fiatConnectReviewScreen.paymentMethodVia, {"providerName":"Provider Two"}', ]) }) it('shows the fees even if the prepared transaction is loading', async () => { mockPrepareERC20TransferTransaction.mockImplementation(async () => { return new Promise(() => { // do nothing so that the loading state persists }) }) const props = getProps(CICOFlow.CashIn, true, CryptoType.cUSD) const { findByTestId } = render( ) expect(await findByTestId('txDetails-fee/value')).toHaveTextContent('$0.70') }) it('shows the fees even if the prepared transaction has an error', async () => { mockPrepareERC20TransferTransaction.mockRejectedValue(new Error('some error')) const props = getProps(CICOFlow.CashIn, true, CryptoType.cUSD) const { findByTestId } = render( ) expect(await findByTestId('txDetails-fee/value')).toHaveTextContent('$0.70') }) }) describe('cashOut', () => { beforeEach(() => { store.clearActions() jest.clearAllMocks() }) const mockPreparedTransaction: TransactionRequest = { from: mockAccount, to: '0x123', value: BigInt(0), data: '0xtransferEncodedData', gas: BigInt(3_000_000), maxFeePerGas: parseGwei('5'), _baseFeePerGas: parseGwei('1'), feeCurrency: mockCusdAddress as Address, } it('shows fiat amount, transaction details and payment method, with provider and network fees', async () => { mockPrepareERC20TransferTransaction.mockResolvedValue({ type: 'possible', transactions: [ { ...mockPreparedTransaction, gas: BigInt(4_000_000), // max gas = gas * maxFeePerGas = 0.02 cEUR feeCurrency: mockCeurAddress as Address, }, ], feeCurrency: mockCeurTokenBalance, }) const { queryByTestId, queryByText } = render( ) await waitFor(() => expect(queryByText('fiatConnectReviewScreen.bankFeeDisclaimer')).toBeFalsy() ) expect(queryByTestId('receive-amount/value')?.children).toEqual(['$', '100.00']) expect(queryByText('fiatConnectReviewScreen.transactionDetails')).toBeTruthy() expect(queryByText('fiatConnectReviewScreen.cashOut.transactionDetailsAmount')).toBeTruthy() expect(queryByTestId('txDetails-total')?.children).toEqual(['100.02', ' cEUR']) expect(queryByTestId('txDetails-converted')?.children).toEqual(['99.47', ' cEUR']) expect(queryByTestId('txDetails-fee')?.children).toEqual(['0.55', ' cEUR']) expect(queryByTestId('txDetails-exchangeRate/value')?.children).toEqual(['$', '1.0053']) expect(queryByTestId('txDetails-receive/value')?.children).toEqual(['$', '100.00']) expect(queryByText('fiatConnectReviewScreen.cashOut.paymentMethodHeader')).toBeTruthy() expect(queryByTestId('paymentMethod-text')?.children).toEqual(['Chase (...2345)']) expect(queryByTestId('paymentMethod-via')?.children).toEqual([ 'fiatConnectReviewScreen.paymentMethodVia, {"providerName":"Provider Two"}', ]) }) it('dispatches refetchQuote when shouldRefetchQuote is true', async () => { const props = getProps(CICOFlow.CashOut, true, CryptoType.cEUR, true) render( ) await waitFor(() => expect(store.getActions()).toEqual([ refetchQuote({ flow: CICOFlow.CashOut, cryptoType: props.route.params.normalizedQuote.getCryptoType(), cryptoAmount: props.route.params.normalizedQuote.getCryptoAmount(), fiatAmount: props.route.params.normalizedQuote.getFiatAmount(), providerId: props.route.params.normalizedQuote.getProviderId(), fiatAccount: props.route.params.fiatAccount, tokenId: props.route.params.normalizedQuote.getTokenId(), }), ]) ) }) it('shows an error page when fiatConnectQuotesError is truthy, try again button dispatches refetchQuote', async () => { const props = getProps(CICOFlow.CashOut, true, CryptoType.cEUR, false) const mockStore = createMockStore({ fiatConnect: { quotesError: 'error', }, }) const { getByTestId } = render( ) await waitFor(() => expect(mockStore.getActions()).toEqual([])) fireEvent.press(getByTestId('TryAgain')) await waitFor(() => expect(mockStore.getActions()).toEqual([ refetchQuote({ flow: CICOFlow.CashOut, cryptoType: props.route.params.normalizedQuote.getCryptoType(), cryptoAmount: props.route.params.normalizedQuote.getCryptoAmount(), fiatAmount: props.route.params.normalizedQuote.getFiatAmount(), providerId: props.route.params.normalizedQuote.getProviderId(), fiatAccount: props.route.params.fiatAccount, tokenId: props.route.params.normalizedQuote.getTokenId(), }), ]) ) }) it('shows fiat amount, transaction details and payment method without provider fee', async () => { mockPrepareERC20TransferTransaction.mockResolvedValue({ type: 'possible', transactions: [ { ...mockPreparedTransaction, gas: BigInt(3_000_000), // max gas = gas * maxFeePerGas = 0.015 cUSD }, ], feeCurrency: mockCusdTokenBalance, }) const { queryByTestId, queryByText } = render( ) await waitFor(() => expect(queryByTestId('receive-amount/value')?.children).toEqual(['$', '100.00']) ) expect(queryByText('fiatConnectReviewScreen.transactionDetails')).toBeTruthy() expect(queryByText('fiatConnectReviewScreen.cashOut.transactionDetailsAmount')).toBeTruthy() expect(queryByTestId('txDetails-total')?.children).toEqual(['100.02', ' cUSD']) expect(queryByTestId('txDetails-converted')?.children).toEqual(['100.00', ' cUSD']) expect(queryByTestId('txDetails-fee')?.children).toEqual(['0.015', ' cUSD']) expect(queryByTestId('txDetails-exchangeRate/value')?.children).toEqual(['$', '1']) expect(queryByTestId('txDetails-receive/value')?.children).toEqual(['$', '100.00']) expect(queryByText('fiatConnectReviewScreen.cashOut.paymentMethodHeader')).toBeTruthy() expect(queryByTestId('paymentMethod-text')?.children).toEqual(['Chase (...2345)']) expect(queryByTestId('paymentMethod-via')?.children).toEqual([ 'fiatConnectReviewScreen.paymentMethodVia, {"providerName":"Provider Two"}', ]) }) it('disables the submit button if prepared transaction is not possible', async () => { mockPrepareERC20TransferTransaction.mockResolvedValue({ type: 'not-enough-balance-for-gas', feeCurrencies: [mockCusdTokenBalance], }) const props = getProps(CICOFlow.CashOut, false, CryptoType.cUSD, false) const { findByTestId } = render( ) expect(await findByTestId('submitButton')).toBeDisabled() }) it('shows a loading spinner for fees if there is a provider fee and the prepared transaction is loading', async () => { mockPrepareERC20TransferTransaction.mockImplementation(async () => { return new Promise(() => { // do nothing so that the loading state persists }) }) const props = getProps(CICOFlow.CashOut, true, CryptoType.cUSD, false) const { getByTestId } = render( ) await waitFor(() => expect(getByTestId('LineItemLoading')).toBeTruthy()) }) it('shows a --- for fees if there is a provider fee and there was an error preparing transactions', async () => { mockPrepareERC20TransferTransaction.mockRejectedValue(new Error('some error')) const props = getProps(CICOFlow.CashOut, true, CryptoType.cUSD, false) const { getByTestId } = render( ) await waitFor(() => expect(getByTestId('LineItemRow/feeEstimateRow')).toHaveTextContent('---') ) }) it('shows expired dialog when quote is expired', async () => { const expireMs = -100 const props = getProps(CICOFlow.CashOut, false, CryptoType.cUSD, false, expireMs) const quote = _.cloneDeep(mockFiatConnectQuotes[1]) as FiatConnectQuoteSuccess quote.quote.guaranteedUntil = new Date(Date.now() + expireMs).toISOString() const mockStore = createMockStore({ fiatConnect: { quotes: [quote], }, }) const { getByTestId } = render( ) await waitFor(() => expect(getByTestId('expiredQuoteDialog')?.props.visible).toEqual(true)) fireEvent.press(getByTestId('expiredQuoteDialog/PrimaryAction')) await waitFor(() => expect(mockStore.getActions()).toEqual([ refetchQuote({ flow: CICOFlow.CashOut, cryptoType: props.route.params.normalizedQuote.getCryptoType(), cryptoAmount: props.route.params.normalizedQuote.getCryptoAmount(), fiatAmount: props.route.params.normalizedQuote.getFiatAmount(), providerId: props.route.params.normalizedQuote.getProviderId(), fiatAccount: props.route.params.fiatAccount, tokenId: props.route.params.normalizedQuote.getTokenId(), }), ]) ) }) it('shows expired dialog when submitting expired quote', async () => { mockPrepareERC20TransferTransaction.mockResolvedValue({ type: 'possible', transactions: [ { ...mockPreparedTransaction, gas: BigInt(3_000_000), // max gas = gas * maxFeePerGas = 0.015 cUSD }, ], feeCurrency: mockCusdTokenBalance, }) const expireMs = 100 const mockProps = getProps(CICOFlow.CashOut, false, CryptoType.cUSD, false, expireMs) const { getByTestId, queryByTestId } = render( ) await act(() => { jest.advanceTimersByTime(expireMs + 1) }) expect(queryByTestId('expiredQuoteDialog')).toBeFalsy() fireEvent.press(getByTestId('submitButton')) expect(store.getActions()).toEqual([]) await waitFor(() => expect(getByTestId('expiredQuoteDialog').props.visible).toEqual(true)) }) it('dispatches fiat transfer action and navigates on clicking button', async () => { mockPrepareERC20TransferTransaction.mockResolvedValue({ type: 'possible', transactions: [mockPreparedTransaction], feeCurrency: mockCusdTokenBalance, }) const mockProps = getProps(CICOFlow.CashOut) const { getByTestId } = render( ) await waitFor(() => expect(getByTestId('submitButton')).toBeEnabled()) fireEvent.press(getByTestId('submitButton')) expect(store.getActions()).toEqual([ createFiatConnectTransfer({ flow: CICOFlow.CashOut, fiatConnectQuote: mockProps.route.params.normalizedQuote, fiatAccountId: '123', networkId: NetworkId['celo-alfajores'], serializablePreparedTransaction: getSerializablePreparedTransaction(mockPreparedTransaction), }), ]) expect(navigate).toHaveBeenCalledWith(Screens.FiatConnectTransferStatus, { flow: CICOFlow.CashOut, normalizedQuote: mockProps.route.params.normalizedQuote, fiatAccount: mockProps.route.params.fiatAccount, }) }) it('navigates back to select providers screen when the provider is pressed', async () => { const mockProps = getProps(CICOFlow.CashOut) const { findByTestId } = render( ) fireEvent.press(await findByTestId('paymentMethod-text')) expect(navigate).toHaveBeenCalledWith(Screens.SelectProvider, { flow: CICOFlow.CashOut, amount: { fiat: 100, crypto: 100, }, tokenId: mockCusdTokenId, }) }) describe.each([ [FiatAccountType.BankAccount, 'fiatConnectReviewScreen.bankFeeDisclaimer'], [FiatAccountType.MobileMoney, 'fiatConnectReviewScreen.mobileMoneyFeeDisclaimer'], ])('Fee Disclaimer for %s', (accountType, disclaimer) => { const mockProps = getProps(CICOFlow.CashOut) mockProps.route.params.fiatAccount.fiatAccountType = accountType it(`${accountType} does not show disclaimer when quote fiat currency matches locale currency`, async () => { const { queryByText } = render( ) await waitFor(() => expect(queryByText(disclaimer)).toBeFalsy()) }) it(`${accountType} shows disclaimer when quote fiat currency does not match locale currency`, async () => { jest .mocked(getDefaultLocalCurrencyCode) .mockReturnValue('Locale Currency' as LocalCurrencyCode) const { findByText } = render( ) expect(await findByText(disclaimer)).toBeTruthy() }) }) }) })