import { useMorphoVault } from '@/earn/hooks/useMorphoVault';
import { useGetTokenBalance } from '@/wallet/hooks/useGetTokenBalance';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { fireEvent, render, renderHook, screen } from '@testing-library/react';
import { act } from 'react';
import type { TransactionReceipt } from 'viem';
import { baseSepolia } from 'viem/chains';
import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest';
import { http, WagmiProvider, createConfig, mock, useAccount } from 'wagmi';
import { EarnProvider, useEarnContext } from './EarnProvider';
const DUMMY_ADDRESS = '0x9E95f497a7663B70404496dB6481c890C4825fe1' as const;
const queryClient = new QueryClient();
const mockConfig = createConfig({
chains: [baseSepolia],
connectors: [
mock({
accounts: ['0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'],
}),
],
transports: {
[baseSepolia.id]: http(),
},
});
const TestComponent = () => {
const context = useEarnContext();
const handleStatusError = async () => {
context.updateLifecycleStatus({
statusName: 'error',
statusData: {
code: 'code',
error: 'error_long_messages',
message: 'error_long_messages',
},
});
};
const handleStatusSuccess = async () => {
context.updateLifecycleStatus({
statusName: 'success',
statusData: {
transactionReceipts: [
{ hash: '0x1235' } as unknown as TransactionReceipt,
],
},
});
};
return (
{context.lifecycleStatus.statusName}
);
};
const wrapper = ({ children }: { children?: React.ReactNode }) => (
{children}
);
vi.mock('@/wallet/hooks/useGetTokenBalance', () => ({
useGetTokenBalance: vi.fn(),
}));
vi.mock('wagmi', async (importOriginal) => {
return {
...(await importOriginal()),
useAccount: vi.fn(),
useConfig: vi.fn(),
};
});
vi.mock('@/earn/hooks/useMorphoVault', () => ({
useMorphoVault: vi.fn(),
}));
describe('EarnProvider', () => {
beforeEach(() => {
(useAccount as Mock).mockReturnValue({
address: DUMMY_ADDRESS,
});
(useGetTokenBalance as Mock).mockReturnValue({
convertedBalance: '0.0',
error: null,
});
});
it('should emit onError when setLifecycleStatus is called with error', async () => {
const onErrorMock = vi.fn();
const onStatusMock = vi.fn();
(useMorphoVault as Mock).mockReturnValue({
asset: DUMMY_ADDRESS,
assetDecimals: 18,
assetSymbol: 'TEST',
balance: '100',
totalApy: '0.05',
});
render(
,
);
const button = screen.getByText('setLifecycleStatus.error');
fireEvent.click(button);
expect(onErrorMock).toHaveBeenCalled();
expect(onStatusMock).toHaveBeenCalled();
});
it('should emit onSuccess when setLifecycleStatus is called with success', async () => {
const onSuccessMock = vi.fn();
(useMorphoVault as Mock).mockReturnValue({
asset: DUMMY_ADDRESS,
assetDecimals: 18,
assetSymbol: 'TEST',
balance: '100',
totalApy: '0.05',
});
render(
,
);
const button = screen.getByText('setLifecycleStatus.success');
fireEvent.click(button);
expect(onSuccessMock).toHaveBeenCalledWith({ hash: '0x1235' });
});
it('throws an error when vaultAddress is not provided', () => {
expect(() =>
renderHook(() => useEarnContext(), {
wrapper: ({ children }) => (
// @ts-expect-error - vaultAddress is not provided
{children}
),
}),
).toThrow('vaultAddress is required');
});
it('provides vault address through context', () => {
(useMorphoVault as Mock).mockReturnValue({
asset: DUMMY_ADDRESS,
assetDecimals: 18,
assetSymbol: 'TEST',
balance: '100',
totalApy: '0.05',
});
const { result } = renderHook(() => useEarnContext(), { wrapper });
expect(result.current.vaultAddress).toBe(DUMMY_ADDRESS);
});
it('throws error when useEarnContext is used outside of provider', () => {
expect(() => renderHook(() => useEarnContext())).toThrow(
'useEarnContext must be used within an EarnProvider',
);
});
it('handles case when asset exists', () => {
(useMorphoVault as Mock).mockReturnValue({
asset: DUMMY_ADDRESS,
assetDecimals: 18,
assetSymbol: 'TEST',
balance: '100',
totalApy: '0.05',
});
render(
Child
,
);
});
it('returns undefined vaultToken when asset is undefined', () => {
(useMorphoVault as Mock).mockReturnValue({
vaultToken: undefined,
});
const { result } = renderHook(() => useEarnContext(), { wrapper });
expect(result.current.vaultToken).toBeUndefined();
});
it('updates lifecycle status and deposit amount when handleDepositAmount is called', async () => {
(useMorphoVault as Mock).mockReturnValue({
asset: DUMMY_ADDRESS,
assetDecimals: 18,
assetSymbol: 'TEST',
balance: '100',
totalApy: '0.05',
});
const { result } = renderHook(() => useEarnContext(), { wrapper });
await act(async () => {
result.current.setDepositAmount('100');
});
expect(result.current.depositAmount).toBe('100');
expect(result.current.lifecycleStatus).toEqual({
statusName: 'amountChange',
statusData: {
amount: '100',
token: result.current.vaultToken,
},
});
});
it('updates lifecycle status and withdraw amount when handleWithdrawAmount is called', async () => {
(useMorphoVault as Mock).mockReturnValue({
asset: DUMMY_ADDRESS,
assetDecimals: 18,
assetSymbol: 'TEST',
balance: '100',
totalApy: '0.05',
});
const { result } = renderHook(() => useEarnContext(), { wrapper });
await act(async () => {
result.current.setWithdrawAmount('50');
});
expect(result.current.withdrawAmount).toBe('50');
expect(result.current.lifecycleStatus).toEqual({
statusName: 'amountChange',
statusData: {
amount: '50',
token: result.current.vaultToken,
},
});
});
// Input validation
// Deposit
it('returns an error when depositAmount is set to 0', async () => {
const { result } = renderHook(() => useEarnContext(), { wrapper });
await act(async () => {
result.current.setDepositAmount('0');
});
expect(result.current.depositAmountError).toBe('Must be greater than 0');
});
it('returns null when depositAmount is set to a positive number less than or equal to underlyingBalance', async () => {
(useGetTokenBalance as Mock).mockReturnValue({
convertedBalance: '100',
});
const { result } = renderHook(() => useEarnContext(), { wrapper });
await act(async () => {
result.current.setDepositAmount('10');
});
expect(result.current.depositAmountError).toBeNull();
});
// Withdraw
it('returns an error when withdrawAmount is set to 0', async () => {
const { result } = renderHook(() => useEarnContext(), { wrapper });
await act(async () => {
result.current.setWithdrawAmount('0');
});
expect(result.current.withdrawAmountError).toBe('Must be greater than 0');
});
it('returns an error when withdrawAmount is greater than receiptBalance', async () => {
(useMorphoVault as Mock).mockReturnValue({
balance: '1',
});
const { result } = renderHook(() => useEarnContext(), { wrapper });
await act(async () => {
result.current.setWithdrawAmount('2');
});
expect(result.current.withdrawAmountError).toBe(
'Amount exceeds the balance',
);
});
it('passes token decimals to withdrawCalls when vaultToken exists', () => {
(useMorphoVault as Mock).mockReturnValue({
asset: {
address: DUMMY_ADDRESS,
decimals: 18,
symbol: 'TEST',
},
balance: '100',
totalApy: '0.05',
});
const { result } = renderHook(() => useEarnContext(), { wrapper });
expect(result.current.vaultToken?.decimals).toBe(18);
});
it('handles undefined token decimals in withdrawCalls when vaultToken is undefined', () => {
(useMorphoVault as Mock).mockReturnValue({
asset: undefined,
balance: '100',
totalApy: '0.05',
});
const { result } = renderHook(() => useEarnContext(), { wrapper });
expect(result.current.vaultToken).toBeUndefined();
});
});