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();
});
});