import { openPopup } from '@/internal/utils/openPopup'; import { useOnchainKit } from '@/useOnchainKit'; import { act } from 'react'; import { useIsWalletACoinbaseSmartWallet } from '@/wallet/hooks/useIsWalletACoinbaseSmartWallet'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { type Mock, afterEach, beforeEach, describe, expect, it, vi, } from 'vitest'; import { useAccount, useConnect, useSwitchChain, useWaitForTransactionReceipt, useSendCalls, useCallsStatus, } from 'wagmi'; import { useAnalytics } from '../../core/analytics/hooks/useAnalytics'; import { CheckoutEvent } from '../../core/analytics/types'; import { GENERIC_ERROR_MESSAGE } from '../constants'; import { useCommerceContracts } from '../hooks/useCommerceContracts'; import { CheckoutProvider, useCheckoutContext } from './CheckoutProvider'; vi.mock('wagmi', () => ({ useAccount: vi.fn(), useConnect: vi.fn(), useSwitchChain: vi.fn(), useWaitForTransactionReceipt: vi.fn(), useSendCalls: vi.fn(), useCallsStatus: vi.fn(), })); vi.mock('../hooks/useCommerceContracts', () => ({ useCommerceContracts: vi.fn(), })); vi.mock('../../wallet/hooks/useIsWalletACoinbaseSmartWallet', () => ({ useIsWalletACoinbaseSmartWallet: vi.fn(), })); vi.mock('@/useOnchainKit', () => ({ useOnchainKit: vi.fn(), })); vi.mock('@/internal/utils/openPopup', () => ({ openPopup: vi.fn(), })); vi.mock('../../core/analytics/hooks/useAnalytics', () => ({ useAnalytics: vi.fn(() => ({ sendAnalytics: vi.fn(), })), })); const windowOpenMock = vi.fn(); const TestComponent = () => { const context = useCheckoutContext(); return (
{context.lifecycleStatus?.statusName} {context.lifecycleStatus?.statusName === 'error' && ( {context.lifecycleStatus?.statusData.message} )} {context.errorMessage}
); }; describe('CheckoutProvider', () => { beforeEach(() => { vi.resetAllMocks(); vi.spyOn(window, 'open').mockImplementation(windowOpenMock); (useOnchainKit as Mock).mockReturnValue({ config: { paymaster: null }, }); (useAccount as Mock).mockReturnValue({ address: '0x123', chainId: 1, isConnected: true, }); (useConnect as Mock).mockReturnValue({ connectAsync: vi.fn(), connectors: [], }); (useSwitchChain as Mock).mockReturnValue({ switchChainAsync: vi.fn() }); (useCallsStatus as Mock).mockReturnValue({ data: null }); (useWaitForTransactionReceipt as Mock).mockReturnValue({ data: null }); (useSendCalls as Mock).mockReturnValue({ status: 'idle', sendCallsAsync: vi.fn(), }); (useCommerceContracts as Mock).mockReturnValue(() => Promise.resolve({ contracts: [{}], insufficientBalance: false }), ); (useIsWalletACoinbaseSmartWallet as Mock).mockReturnValue(true); }); afterEach(() => { windowOpenMock.mockReset(); }); it('should render children', async () => { await act(async () => { render( , ); }); expect(screen.getByTestId('test-component')).toBeTruthy(); }); it('should handle user rejected request', async () => { (useCommerceContracts as Mock).mockReturnValue(() => Promise.resolve({ insufficientBalance: false, contracts: [{}], priceInUSDC: '10', }), ); (useSendCalls as Mock).mockImplementation(() => { return { status: 'error', sendCallsAsync: vi .fn() .mockRejectedValue(new Error('User denied connection request.')), }; }); render( , ); fireEvent.click(screen.getByText('Submit')); await waitFor(() => { expect(screen.getByTestId('error-message').textContent).toBe( 'Request denied.', ); }); }); it('should clear user rejected request on next button press', async () => { (useCommerceContracts as Mock).mockReturnValue(() => Promise.resolve({ insufficientBalance: false, contracts: [{}], priceInUSDC: '10', }), ); (useSendCalls as Mock).mockImplementation(() => { return { status: 'error', sendCallsAsync: vi .fn() .mockRejectedValue(new Error('User denied connection request.')), }; }); render( , ); fireEvent.click(screen.getByText('Submit')); await waitFor(() => { expect(screen.getByTestId('error-message').textContent).toBe( 'Request denied.', ); }); fireEvent.click(screen.getByText('Submit')); await waitFor(() => { expect(screen.getByTestId('error-message').textContent).toBe(''); }); }); it('should handle other errors', async () => { (useCommerceContracts as Mock).mockReturnValue(() => Promise.resolve({ insufficientBalance: false, contracts: [{}], priceInUSDC: '10', }), ); (useSendCalls as Mock).mockImplementation(() => { return { status: 'error', sendCallsAsync: vi .fn() .mockRejectedValue(new Error('some other error')), }; }); render( , ); fireEvent.click(screen.getByText('Submit')); await waitFor(() => { expect(screen.getByTestId('error-message').textContent).toBe( GENERIC_ERROR_MESSAGE, ); }); }); it('should handle successful transaction', async () => { (useWaitForTransactionReceipt as Mock).mockReturnValue({ data: { status: 'success' }, }); render( , ); fireEvent.click(screen.getByText('Submit')); await waitFor(() => { expect(screen.getByTestId('lifecycle-status').textContent).toBe( 'success', ); }); }); it('should handle connection request', async () => { (useAccount as Mock).mockReturnValue({ address: undefined, chainId: undefined, isConnected: false, }); (useConnect as Mock).mockReturnValue({ connectAsync: vi .fn() .mockResolvedValue({ accounts: ['0x123'], chainId: 1 }), connectors: [{ id: 'coinbaseWalletSDK' }], }); render( , ); fireEvent.click(screen.getByText('Submit')); await waitFor(() => { expect(useConnect().connectAsync).toHaveBeenCalled(); }); }); it('should default to coinbase wallet in connection request if not connected', async () => { (useAccount as Mock).mockReturnValue({ address: undefined, chainId: undefined, isConnected: false, }); (useConnect as Mock).mockReturnValue({ connectAsync: vi .fn() .mockResolvedValue({ accounts: ['0x123'], chainId: 1 }), connectors: [{ id: 'not-coinbase' }], }); render( , ); fireEvent.click(screen.getByText('Submit')); await waitFor(() => { expect(useConnect().connectAsync).toHaveBeenCalled(); }); }); it('should default to coinbase wallet in connection request if not smart wallet', async () => { (useIsWalletACoinbaseSmartWallet as Mock).mockReturnValue(false); render( , ); fireEvent.click(screen.getByText('Submit')); await waitFor(() => { expect(useConnect().connectAsync).toHaveBeenCalled(); }); }); it('should call the provided onStatus', async () => { (useAccount as Mock).mockReturnValue({ address: undefined, chainId: undefined, isConnected: false, }); (useConnect as Mock).mockReturnValue({ connectAsync: vi .fn() .mockResolvedValue({ accounts: ['0x123'], chainId: 1 }), connectors: [{ id: 'coinbaseWalletSDK' }], }); const onStatus = vi.fn(); (useWaitForTransactionReceipt as Mock).mockReturnValue({ data: { status: 'success' }, }); render( , ); fireEvent.click(screen.getByText('Submit')); await waitFor(() => { expect(onStatus).toHaveBeenCalled(); }); expect(onStatus).toHaveBeenNthCalledWith(1, { statusName: 'init', statusData: {}, }); expect(onStatus).toHaveBeenNthCalledWith( 2, expect.objectContaining({ statusName: 'success' }), ); }); it('should handle chain switching', async () => { (useAccount as Mock).mockReturnValue({ address: '0x123', chainId: 1, isConnected: true, }); (useSwitchChain as Mock).mockReturnValue({ switchChainAsync: vi.fn().mockResolvedValue({}), }); render( , ); fireEvent.click(screen.getByText('Submit')); await waitFor(() => { expect(useSwitchChain().switchChainAsync).toHaveBeenCalledWith({ chainId: 8453, }); // Base chain ID }); }); it('should update status when writeContracts is pending', async () => { (useAccount as Mock).mockReturnValue({ address: undefined, chainId: undefined, isConnected: false, }); (useConnect as Mock).mockReturnValue({ connectAsync: vi .fn() .mockResolvedValue({ accounts: ['0x123'], chainId: 1 }), connectors: [{ id: 'coinbaseWalletSDK' }], }); (useSendCalls as Mock).mockReturnValue({ status: 'pending', sendCallsAsync: vi.fn(), }); render( , ); fireEvent.click(screen.getByText('Submit')); await waitFor(() => { expect(screen.getByTestId('lifecycle-status').textContent).toBe( 'pending', ); }); }); it('should handle successful transaction receipt', async () => { const mockReceipt = { status: 'success', transactionHash: '0x123' }; (useWaitForTransactionReceipt as Mock).mockReturnValue({ data: mockReceipt, }); (useCallsStatus as Mock).mockReturnValue({ data: { receipts: [{ transactionHash: '0x123' }] }, }); render( , ); fireEvent.click(screen.getByText('Submit')); await waitFor(() => { expect(screen.getByTestId('lifecycle-status').textContent).toBe( 'success', ); }); }); it('should open receipt URL on success', async () => { (useWaitForTransactionReceipt as Mock).mockReturnValue({ data: { status: 'success' }, }); render( , ); fireEvent.click(screen.getByText('Submit')); await waitFor(() => { expect(screen.getByTestId('lifecycle-status').textContent).toBe( 'success', ); }); fireEvent.click(screen.getByText('Submit')); expect(windowOpenMock).toHaveBeenCalledWith( expect.stringContaining('https://commerce.coinbase.com/pay/'), '_blank', 'noopener,noreferrer', ); }); it('should open funding flow for insufficient balance', async () => { (useCommerceContracts as Mock).mockReturnValue(() => Promise.resolve({ insufficientBalance: true, priceInUSDC: '10' }), ); render( , ); fireEvent.click(screen.getByText('Submit')); await waitFor(() => { expect(openPopup as Mock).toHaveBeenCalledWith( expect.objectContaining({ url: 'https://keys.coinbase.com/fund?asset=USDC&chainId=8453&presetCryptoAmount=10', }), ); }); }); it('should handle errors when fetching contracts', async () => { (useCommerceContracts as Mock).mockReturnValue(() => Promise.resolve({ error: new Error('Failed to fetch contracts') }), ); render( , ); fireEvent.click(screen.getByText('Submit')); await waitFor(() => { expect(screen.getByTestId('error-message').textContent).toBe( GENERIC_ERROR_MESSAGE, ); expect(screen.getByTestId('lifecycle-status').textContent).toBe('error'); }); }); it('should handle no contracts error', async () => { (useCommerceContracts as Mock).mockReturnValue(() => { return Promise.resolve({ insufficientBalance: false, contracts: undefined, priceInUSDC: '10', }); }); render( , ); fireEvent.click(screen.getByText('Submit')); await waitFor(() => { expect(screen.getByTestId('error-message').textContent).toBe( GENERIC_ERROR_MESSAGE, ); expect(screen.getByTestId('lifecycle-status').textContent).toBe('error'); }); }); it('should handle successful contract calls', async () => { const mockSendCallsAsync = vi.fn().mockResolvedValue({}); (useCommerceContracts as Mock).mockReturnValue(() => { return Promise.resolve({ insufficientBalance: false, contracts: [ { address: '0x1234567890123456789012345678901234567890', abi: [], functionName: 'testFunction', args: [], }, ], priceInUSDC: '10', }); }); (useSendCalls as Mock).mockReturnValue({ status: 'success', sendCallsAsync: mockSendCallsAsync, }); render( , ); fireEvent.click(screen.getByText('Submit')); await waitFor(() => { expect(mockSendCallsAsync).toHaveBeenCalled(); }); expect(mockSendCallsAsync).toHaveBeenCalledWith({ calls: [ { to: '0x1234567890123456789012345678901234567890', abi: [], functionName: 'testFunction', args: [], }, ], capabilities: undefined, }); }); it('should handle sponsored contract calls', async () => { (useOnchainKit as Mock).mockReturnValue({ config: { paymaster: 'http://example.com' }, }); const mockSendCallsAsync = vi.fn().mockResolvedValue({}); (useCommerceContracts as Mock).mockReturnValue(() => { return Promise.resolve({ insufficientBalance: false, contracts: [ { address: '0x1234567890123456789012345678901234567890', abi: [], functionName: 'testFunction', args: [], }, ], priceInUSDC: '10', }); }); (useSendCalls as Mock).mockReturnValue({ status: 'success', sendCallsAsync: mockSendCallsAsync, }); render( , ); fireEvent.click(screen.getByText('Submit')); await waitFor(() => { expect(mockSendCallsAsync).toHaveBeenCalled(); }); expect(mockSendCallsAsync).toHaveBeenCalledWith({ calls: [ { to: '0x1234567890123456789012345678901234567890', abi: [], functionName: 'testFunction', args: [], }, ], capabilities: { paymasterService: { url: 'http://example.com', }, }, }); }); describe('analytics', () => { let sendAnalytics: Mock; beforeEach(() => { sendAnalytics = vi.fn(); (useAnalytics as Mock).mockImplementation(() => ({ sendAnalytics, })); (useCommerceContracts as Mock).mockReturnValue(() => Promise.resolve({ insufficientBalance: false, contracts: [{}], priceInUSDC: '10.00', }), ); }); it('should track checkout initiated', async () => { render( , ); await waitFor(() => { expect(screen.getByTestId('lifecycle-status').textContent).toBe( 'ready', ); }); fireEvent.click(screen.getByText('Submit')); await waitFor(() => { expect(sendAnalytics).toHaveBeenCalledWith( CheckoutEvent.CheckoutInitiated, { address: '0x123', amount: 10, productId: 'test-product', }, ); }); }); it('should track checkout failure', async () => { const error = new Error('Test error'); (useSendCalls as Mock).mockImplementation(() => ({ status: 'error', sendCallsAsync: vi.fn().mockRejectedValue(error), })); render( , ); fireEvent.click(screen.getByText('Submit')); await waitFor(() => { expect(sendAnalytics).toHaveBeenCalledWith( CheckoutEvent.CheckoutFailure, { error: 'Test error', metadata: { error: JSON.stringify(error) }, }, ); }); }); it('should track checkout failure with unknown error', async () => { (useSendCalls as Mock).mockImplementation(() => ({ status: 'error', sendCallsAsync: vi.fn().mockRejectedValue('string error'), })); render( , ); fireEvent.click(screen.getByText('Submit')); await waitFor(() => { expect(sendAnalytics).toHaveBeenCalledTimes(2); expect(sendAnalytics).toHaveBeenNthCalledWith( 1, CheckoutEvent.CheckoutInitiated, { address: '0x123', amount: 0, productId: 'test-product', }, ); expect(sendAnalytics).toHaveBeenNthCalledWith( 2, CheckoutEvent.CheckoutFailure, { error: 'Checkout failed', metadata: { error: JSON.stringify('string error') }, }, ); }); }); }); }); describe('useCheckoutContext', () => { it('should throw an error when used outside of CheckoutProvider', () => { const TestComponent = () => { useCheckoutContext(); return null; }; const consoleError = vi .spyOn(console, 'error') .mockImplementation(() => {}); // Suppress error logging expect(() => render()).toThrow( 'useCheckoutContext must be used within a Checkout component', ); consoleError.mockRestore(); }); });