import { TransactionEvent } from '@/core/analytics/types'; import { act } from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { base } from 'viem/chains'; import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; import { useAccount, useSwitchChain, useWaitForTransactionReceipt, } from 'wagmi'; import { waitForTransactionReceipt } from 'wagmi/actions'; import { useCapabilitiesSafe } from '../../internal/hooks/useCapabilitiesSafe'; import { useOnchainKit } from '../../useOnchainKit'; import { useCallsStatus } from '../hooks/useCallsStatus'; import { useSendCall } from '../hooks/useSendCall'; import { useSendCalls } from '../hooks/useSendCalls'; import { useSendWalletTransactions } from '../hooks/useSendWalletTransactions'; import { TransactionProvider, useTransactionContext, } from './TransactionProvider'; vi.mock('wagmi', () => ({ useAccount: vi.fn(), useSwitchChain: vi.fn(), useWaitForTransactionReceipt: vi.fn(), useConfig: vi.fn(), waitForTransactionReceipt: vi.fn(), })); vi.mock('wagmi/actions', () => ({ waitForTransactionReceipt: vi.fn(), })); vi.mock('../hooks/useCallsStatus', () => ({ useCallsStatus: vi.fn(), })); vi.mock('../hooks/useSendCall', () => ({ useSendCall: vi.fn(), })); vi.mock('../hooks/useSendCalls', () => ({ useSendCalls: vi.fn(), })); vi.mock('../hooks/useSendWalletTransactions', () => ({ useSendWalletTransactions: vi.fn(), })); vi.mock('@/internal/hooks/useCapabilitiesSafe', () => ({ useCapabilitiesSafe: vi.fn(), })); vi.mock('@/useOnchainKit', () => ({ useOnchainKit: vi.fn(), })); vi.mock('@/core/analytics/hooks/useAnalytics', () => ({ useAnalytics: vi.fn(() => ({ sendAnalytics: vi.fn(), })), })); const restoreUseSendWalletTransactions = async () => { const mockOriginalModule = await vi.importActual< typeof import('../hooks/useSendWalletTransactions') >('../hooks/useSendWalletTransactions'); (useSendWalletTransactions as ReturnType).mockImplementation( (props) => mockOriginalModule.useSendWalletTransactions(props), ); }; const silenceError = () => { const consoleErrorMock = vi .spyOn(console, 'error') .mockImplementation(() => {}); return () => consoleErrorMock.mockRestore(); }; const TestComponent = () => { const context = useTransactionContext(); const handleStatusError = async () => { context.setLifecycleStatus({ statusName: 'error', statusData: { code: 'code', error: 'error_long_messages', message: 'error_long_messages', }, }); }; const handleStatusTransactionLegacyExecuted = async () => { context.setLifecycleStatus({ statusName: 'transactionLegacyExecuted', statusData: { transactionHashList: ['0xhash12345678'], }, }); }; const handleStatusTransactionLegacyExecutedMultipleContracts = async () => { await context.onSubmit(); await context.setLifecycleStatus({ statusName: 'transactionLegacyExecuted', statusData: { transactionHashList: ['0xhash12345678', '0xhash12345678'], }, }); }; return (
{JSON.stringify(context.transactions)} {context.errorCode} {context.errorMessage} {context.lifecycleStatus.statusName} {`${context.isToastVisible}`}
); }; let mockSendAnalytics: Mock; vi.mock('@/core/analytics/hooks/useAnalytics', () => ({ useAnalytics: () => ({ sendAnalytics: mockSendAnalytics, }), })); describe('TransactionProvider', () => { beforeEach(() => { vi.resetAllMocks(); (useAccount as ReturnType).mockReturnValue({ address: '0xUserAddress', chainId: 1, }); (useSwitchChain as ReturnType).mockReturnValue({ switchChainAsync: vi.fn(), }); (useCallsStatus as ReturnType).mockReturnValue({ transactionHash: null, status: 'idle', }); (useSendCall as ReturnType).mockReturnValue({ status: 'idle', sendCallAsync: vi.fn(), reset: vi.fn(), }); (useSendCalls as ReturnType).mockReturnValue({ status: 'idle', sendCallsAsync: vi.fn(), reset: vi.fn(), }); (useWaitForTransactionReceipt as ReturnType).mockReturnValue({ data: undefined, }); (useCapabilitiesSafe as ReturnType).mockReturnValue({}); (useOnchainKit as Mock).mockReturnValue({ config: { paymaster: null }, }); mockSendAnalytics = vi.fn(); }); describe('analytics', () => { it('tracks transaction initiation', async () => { (useSendWalletTransactions as ReturnType).mockReturnValue( vi.fn(), ); const mockTransactions = [ { to: '0x1234567890123456789012345678901234567890' as `0x${string}`, data: '0x' as `0x${string}`, functionName: 'test', }, ]; render( , ); const button = screen.getByText('Submit'); fireEvent.click(button); await waitFor(async () => { expect(mockSendAnalytics).toHaveBeenCalledWith( TransactionEvent.TransactionInitiated, { address: '0xUserAddress', }, ); }); }); it('tracks transaction success', async () => { (useSendWalletTransactions as ReturnType).mockReturnValue( vi.fn(), ); ( useWaitForTransactionReceipt as ReturnType ).mockReturnValue({ data: { status: 'success', transactionHash: '0xSuccessHash', }, }); (useOnchainKit as Mock).mockReturnValue({ config: { paymaster: 'http://example.com' }, }); render( , ); const button = screen.getByText('Submit'); fireEvent.click(button); await act(async () => { expect(mockSendAnalytics).toHaveBeenCalledWith( TransactionEvent.TransactionSuccess, { paymaster: true, address: '0xUserAddress', transactionHash: '0xSuccessHash', }, ); }); }); it('tracks transaction failure', async () => { ( useWaitForTransactionReceipt as ReturnType ).mockReturnValue({ data: { status: 'reverted', transactionHash: '0xFailHash', }, }); render( , ); const button = screen.getByText('setLifecycleStatus.error'); fireEvent.click(button); await act(() => { expect(mockSendAnalytics).toHaveBeenCalledWith( TransactionEvent.TransactionFailure, { error: 'Transaction failed', metadata: { code: '', }, }, ); }); }); it('does not track analytics for user rejected transactions', async () => { const sendWalletTransactionsMock = vi.fn().mockRejectedValue({ cause: { name: 'UserRejectedRequestError' }, }); (useSendWalletTransactions as ReturnType).mockReturnValue( sendWalletTransactionsMock, ); render( , ); const button = screen.getByText('Submit'); fireEvent.click(button); await waitFor(async () => { expect(mockSendAnalytics).toHaveBeenCalledTimes(1); expect(mockSendAnalytics).not.toHaveBeenCalledWith( TransactionEvent.TransactionFailure, expect.any(Object), ); }); }); }); it('should reset state after specified resetAfter time', async () => { vi.useFakeTimers(); const resetAfter = 1000; (useWaitForTransactionReceipt as ReturnType).mockReturnValue({ data: { status: 'success' }, }); (useSendCall as ReturnType).mockReturnValue({ data: '0xhash12345678', reset: vi.fn(), }); await act(async () => { render( , ); }); expect( screen.getByTestId('context-value-lifecycleStatus-statusName') .textContent, ).toBe('success'); await act(async () => { vi.advanceTimersByTime(resetAfter + 100); }); const mockSendCall = useSendCall as ReturnType; expect(mockSendCall().reset).toHaveBeenCalled(); vi.useRealTimers(); vi.clearAllTimers(); }); it('should cleanup resetAfter timeout on unmount', async () => { (useSendWalletTransactions as ReturnType).mockReturnValue( vi.fn(), ); vi.useFakeTimers(); const resetAfter = 1000; (useWaitForTransactionReceipt as ReturnType).mockReturnValue({ data: { status: 'success' }, }); const { unmount } = render( , ); fireEvent.click(screen.getByText('Submit')); unmount(); vi.advanceTimersByTime(resetAfter + 100); vi.useRealTimers(); }); it('should emit onError when setLifecycleStatus is called with error', async () => { const onErrorMock = vi.fn(); render( , ); const button = screen.getByText('setLifecycleStatus.error'); fireEvent.click(button); expect(onErrorMock).toHaveBeenCalled(); }); it('should emit onStatus when setLifecycleStatus is called with transactionLegacyExecuted', async () => { const onStatusMock = vi.fn(); render( , ); const button = screen.getByText( 'setLifecycleStatus.transactionLegacyExecuted', ); fireEvent.click(button); expect(onStatusMock).toHaveBeenCalledWith({ statusName: 'transactionLegacyExecuted', statusData: { transactionHashList: ['0xhash12345678'], }, }); }); it('should emit onStatus when setLifecycleStatus is called', async () => { const onStatusMock = vi.fn(); render( , ); const button = screen.getByText('setLifecycleStatus.error'); fireEvent.click(button); expect(onStatusMock).toHaveBeenCalled(); }); it('should emit onSuccess when one receipt exist', async () => { (useSendWalletTransactions as ReturnType).mockReturnValue( vi.fn(), ); const onSuccessMock = vi.fn(); (useWaitForTransactionReceipt as ReturnType).mockReturnValue({ data: { status: 'success' }, }); (useCallsStatus as ReturnType).mockReturnValue({ transactionHash: 'hash', }); render( , ); const button = screen.getByText('Submit'); fireEvent.click(button); await act(async () => { expect(onSuccessMock).toHaveBeenCalled(); expect(onSuccessMock).toHaveBeenCalledWith({ transactionReceipts: [{ status: 'success' }], }); }); }); it('should emit onSuccess for multiple contracts using legacy transactions', async () => { (useSendWalletTransactions as ReturnType).mockReturnValue( vi.fn(), ); const onSuccessMock = vi.fn(); (waitForTransactionReceipt as ReturnType).mockReturnValue( 'hash12345678', ); render( , ); const button = screen.getByText( 'setLifecycleStatus.transactionLegacyExecutedMultipleContracts', ); fireEvent.click(button); await waitFor(async () => { expect(onSuccessMock).toHaveBeenCalled(); expect(onSuccessMock).toHaveBeenCalledWith({ transactionReceipts: ['hash12345678', 'hash12345678'], }); }); }); it('should emit onError when building transactions fails', async () => { const sendWalletTransactionsMock = vi.fn(); (useSendWalletTransactions as ReturnType).mockReturnValue( sendWalletTransactionsMock, ); const onErrorMock = vi.fn(); const contracts = () => Promise.reject(new Error('error')); render( , ); const button = screen.getByText('Submit'); fireEvent.click(button); await waitFor(async () => { expect(onErrorMock).toHaveBeenCalledWith({ code: 'TmTPc04', error: '{}', message: 'Error building transactions', }); }); }); it('should emit onError when legacy transactions fail', async () => { const sendWalletTransactionsMock = vi.fn().mockResolvedValue(undefined); (useSendWalletTransactions as ReturnType).mockReturnValue( sendWalletTransactionsMock, ); const onErrorMock = vi.fn(); (waitForTransactionReceipt as ReturnType).mockRejectedValue( new Error('error getting transaction receipt'), ); render( , ); const button = screen.getByText( 'setLifecycleStatus.transactionLegacyExecutedMultipleContracts', ); fireEvent.click(button); await waitFor(async () => { expect(sendWalletTransactionsMock).toHaveBeenCalled(); expect(onErrorMock).toHaveBeenCalledWith({ code: 'TmTPc01', error: '{}', message: 'Something went wrong. Please try again.', }); }); }); it('should set setLifecycleStatus to transactionPending when writeContractsAsync is pending', async () => { await restoreUseSendWalletTransactions(); const sendWalletTransactionsMock = vi.fn(); (useSendWalletTransactions as ReturnType).mockReturnValue( sendWalletTransactionsMock, ); const writeContractsAsyncMock = vi.fn(); (useSendCalls as ReturnType).mockReturnValue({ status: 'pending', writeContractsAsync: writeContractsAsyncMock, }); (useCapabilitiesSafe as ReturnType).mockReturnValue({ atomicBatch: { supported: true }, paymasterService: { supported: true }, auxiliaryFunds: { supported: true }, }); render( , ); const button = screen.getByText('Submit'); fireEvent.click(button); await act(async () => { expect( screen.getByTestId('context-value-lifecycleStatus-statusName') .textContent, ).toBe('transactionPending'); }); }); it('should set setLifecycleStatus to transactionPending when writeContractAsync is pending', async () => { await restoreUseSendWalletTransactions(); const writeContractsAsyncMock = vi.fn(); (useSendCall as ReturnType).mockReturnValue({ status: 'pending', writeContractsAsync: writeContractsAsyncMock, }); render( , ); const button = screen.getByText('Submit'); fireEvent.click(button); await act(async () => { expect( screen.getByTestId('context-value-lifecycleStatus-statusName') .textContent, ).toBe('transactionPending'); }); }); it('should update context on handleSubmit', async () => { const sendWalletTransactionsMock = vi.fn(); (useCapabilitiesSafe as ReturnType).mockReturnValue({ atomicBatch: { supported: true }, paymasterService: { supported: true }, auxiliaryFunds: { supported: true }, }); (useSendWalletTransactions as ReturnType).mockReturnValue( sendWalletTransactionsMock, ); render( , ); const button = screen.getByText('Submit'); fireEvent.click(button); await waitFor(async () => { expect(sendWalletTransactionsMock).toHaveBeenCalled(); }); }); it('should handle errors during submission', async () => { await restoreUseSendWalletTransactions(); const sendCallsAsyncMock = vi .fn() .mockRejectedValue(new Error('Test error')); (useSendCalls as ReturnType).mockReturnValue({ status: 'idle', sendCallsAsync: sendCallsAsyncMock, }); (useCapabilitiesSafe as ReturnType).mockReturnValue({ atomicBatch: { supported: true }, paymasterService: { supported: true }, auxiliaryFunds: { supported: true }, }); render( , ); const button = screen.getByText('Submit'); fireEvent.click(button); await waitFor(async () => { expect(screen.getByTestId('context-value-errorCode').textContent).toBe( 'TmTPc03', ); expect(screen.getByTestId('context-value-errorMessage').textContent).toBe( 'Something went wrong. Please try again.', ); }); }); it('should switch chains when required', async () => { (useSendWalletTransactions as ReturnType).mockReturnValue( vi.fn(), ); const switchChainAsyncMock = vi.fn(); (useSwitchChain as ReturnType).mockReturnValue({ switchChainAsync: switchChainAsyncMock, }); render( , ); const button = screen.getByText('Submit'); fireEvent.click(button); await act(async () => { expect(switchChainAsyncMock).toHaveBeenCalled(); }); }); it('should display toast on error', async () => { (useSendWalletTransactions as ReturnType).mockReturnValue( vi.fn(), ); (useSendCalls as ReturnType).mockReturnValue({ status: 'idle', writeContractsAsync: vi.fn().mockRejectedValue(new Error('Test error')), }); render( , ); const button = screen.getByText('Submit'); fireEvent.click(button); await act(async () => { const testComponent = screen.getByTestId('context-value-isToastVisible'); expect(testComponent.textContent).toBe('true'); }); }); it('should handle user rejected request', async () => { const writeContractsAsyncMock = vi .fn() .mockRejectedValue({ cause: { name: 'UserRejectedRequestError' } }); const sendWalletTransactionsMock = vi.fn().mockRejectedValue({ cause: { name: 'UserRejectedRequestError' }, }); (useSendCalls as ReturnType).mockReturnValue({ status: 'idle', writeContractsAsync: writeContractsAsyncMock, }); (useSendWalletTransactions as ReturnType).mockReturnValue( sendWalletTransactionsMock, ); (useCapabilitiesSafe as ReturnType).mockReturnValue({ atomicBatch: { supported: true }, paymasterService: { supported: true }, auxiliaryFunds: { supported: true }, }); render( , ); const button = screen.getByText('Submit'); fireEvent.click(button); await waitFor(async () => { const errorMessage = screen.getByTestId('context-value-errorMessage'); expect(errorMessage.textContent).toBe('Request denied.'); }); }); it('should handle chain switching', async () => { (useSendWalletTransactions as ReturnType).mockReturnValue( vi.fn(), ); const switchChainAsyncMock = vi.fn(); (useSwitchChain as ReturnType).mockReturnValue({ switchChainAsync: switchChainAsyncMock, }); (useAccount as ReturnType).mockReturnValue({ chainId: 1 }); render( , ); const button = screen.getByText('Submit'); fireEvent.click(button); await act(async () => { expect(switchChainAsyncMock).toHaveBeenCalledWith({ chainId: 2 }); }); }); it('should set transactions based on contracts', async () => { const contracts = [ { to: '0x1234567890123456789012345678901234567890' as `0x${string}`, data: '0x' as `0x${string}`, }, ]; render( , ); await act(async () => { const transactionsElement = screen.getByTestId('transactions'); expect(transactionsElement.textContent).toBe(JSON.stringify(contracts)); }); }); it('should set transactions based on calls', async () => { const calls = [ { to: '0x4567890123456789012345678901234567890123' as `0x${string}`, data: '0xabcdef' as `0x${string}`, }, ]; render( , ); await act(async () => { const transactionsElement = screen.getByTestId('transactions'); expect(transactionsElement.textContent).toBe(JSON.stringify(calls)); }); }); it('should throw an error when calls are not provided', async () => { const restore = silenceError(); expect(() => { render(
Test
, ); }).toThrowError( 'Transaction: calls must be provided as a prop to the Transaction component.', ); restore(); }); it('should throw an error when used outside of TransactionProvider', () => { const TestComponent = () => { useTransactionContext(); return null; }; const consoleError = vi .spyOn(console, 'error') .mockImplementation(() => {}); // Suppress error logging expect(() => render()).toThrow( 'useTransactionContext must be used within a Transaction component', ); consoleError.mockRestore(); }); it('should handle sponsored contract calls', async () => { (useSendWalletTransactions as ReturnType).mockReturnValue( vi.fn(), ); const contracts = [ { to: '0alissa' as `0x${string}`, data: '0x' as `0x${string}` }, ]; const mockCapabilities = { paymasterService: { url: 'http://example.com' }, }; (useOnchainKit as Mock).mockReturnValue({ config: { paymaster: 'http://example.com' }, }); (useCapabilitiesSafe as ReturnType).mockReturnValue({ atomicBatch: { supported: true }, paymasterService: { supported: true }, auxiliaryFunds: { supported: true }, }); const mockWriteContractsAsync = vi.fn().mockResolvedValue({}); (useSendCalls as Mock).mockReturnValue({ status: 'success', writeContractsAsync: mockWriteContractsAsync, }); await act(async () => { render( , ); }); await act(async () => { fireEvent.click(screen.getByText('Submit')); }); expect(useSendWalletTransactions).toHaveBeenLastCalledWith( expect.objectContaining({ capabilities: mockCapabilities, }), ); }); }); describe('useTransactionContext', () => { it('should throw an error when used outside of TransactionProvider', () => { const TestComponent = () => { useTransactionContext(); return null; }; const consoleError = vi .spyOn(console, 'error') .mockImplementation(() => {}); // Suppress error logging expect(() => render()).toThrow( 'useTransactionContext must be used within a Transaction component', ); consoleError.mockRestore(); }); });