diff --git a/src/identity/hooks/useAvatars.test.tsx b/src/identity/hooks/useAvatars.test.tsx new file mode 100644 index 0000000000..5d0deb8963 --- /dev/null +++ b/src/identity/hooks/useAvatars.test.tsx @@ -0,0 +1,267 @@ +import { publicClient } from '@/core/network/client'; +import { getChainPublicClient } from '@/core/network/getChainPublicClient'; +import * as getAvatarsModule from '@/identity/utils/getAvatars'; +import { renderHook, waitFor } from '@testing-library/react'; +import { base, baseSepolia, mainnet, optimism } from 'viem/chains'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getNewReactQueryTestProvider } from './getNewReactQueryTestProvider'; +import { useAvatars } from './useAvatars'; + +vi.mock('@/core/network/client'); + +vi.mock('@/core/network/getChainPublicClient', () => ({ + ...vi.importActual('@/core/network/getChainPublicClient'), + getChainPublicClient: vi.fn(() => publicClient), +})); + +describe('useAvatars', () => { + const mockGetEnsAvatar = publicClient.getEnsAvatar as Mock; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns the correct ENS avatars and loading state', async () => { + const testEnsNames = ['test1.ens', 'test2.ens']; + const testEnsAvatars = ['avatarUrl1', 'avatarUrl2']; + + mockGetEnsAvatar + .mockResolvedValueOnce(testEnsAvatars[0]) + .mockResolvedValueOnce(testEnsAvatars[1]); + + const { result } = renderHook( + () => useAvatars({ ensNames: testEnsNames }), + { + wrapper: getNewReactQueryTestProvider(), + }, + ); + + await waitFor(() => { + expect(result.current.data).toEqual(testEnsAvatars); + expect(result.current.isLoading).toBe(false); + }); + + expect(getChainPublicClient).toHaveBeenCalledWith(mainnet); + }); + + it('returns the loading state true while still fetching ENS avatars', async () => { + const testEnsNames = ['test1.ens', 'test2.ens']; + + const { result } = renderHook( + () => useAvatars({ ensNames: testEnsNames }), + { + wrapper: getNewReactQueryTestProvider(), + }, + ); + + expect(result.current.data).toBe(undefined); + expect(result.current.isLoading).toBe(true); + }); + + it('returns correct base mainnet avatars', async () => { + const testEnsNames = ['shrek.base.eth', 'donkey.base.eth']; + const testEnsAvatars = ['shrekface', 'donkeyface']; + + mockGetEnsAvatar + .mockResolvedValueOnce(testEnsAvatars[0]) + .mockResolvedValueOnce(testEnsAvatars[1]); + + const { result } = renderHook( + () => + useAvatars({ + ensNames: testEnsNames, + chain: base as unknown as typeof mainnet, + }), + { + wrapper: getNewReactQueryTestProvider(), + }, + ); + + await waitFor(() => { + expect(result.current.data).toEqual(testEnsAvatars); + expect(result.current.isLoading).toBe(false); + }); + + expect(getChainPublicClient).toHaveBeenCalledWith(base); + }); + + it('returns correct base sepolia avatars', async () => { + const testEnsNames = ['shrek.basetest.eth', 'donkey.basetest.eth']; + const testEnsAvatars = ['shrektestface', 'donkeytestface']; + + mockGetEnsAvatar + .mockResolvedValueOnce(testEnsAvatars[0]) + .mockResolvedValueOnce(testEnsAvatars[1]); + + const { result } = renderHook( + () => + useAvatars({ + ensNames: testEnsNames, + chain: baseSepolia as unknown as typeof mainnet, + }), + { + wrapper: getNewReactQueryTestProvider(), + }, + ); + + await waitFor(() => { + expect(result.current.data).toEqual(testEnsAvatars); + expect(result.current.isLoading).toBe(false); + }); + + expect(getChainPublicClient).toHaveBeenCalledWith(baseSepolia); + }); + + it('returns error for unsupported chain', async () => { + const testEnsNames = ['shrek.basetest.eth', 'donkey.basetest.eth']; + + const { result } = renderHook( + () => + useAvatars({ + ensNames: testEnsNames, + chain: optimism as unknown as typeof mainnet, + }), + { + wrapper: getNewReactQueryTestProvider(), + }, + ); + + await waitFor(() => { + expect(result.current.data).toBe(undefined); + expect(result.current.isLoading).toBe(false); + expect(result.current.isError).toBe(true); + expect(result.current.error).toBe( + 'ChainId not supported, avatar resolution is only supported on Ethereum and Base.', + ); + }); + }); + + it('respects the enabled option in queryOptions', async () => { + const testEnsNames = ['test1.ens', 'test2.ens']; + const testEnsAvatars = ['avatarUrl1', 'avatarUrl2']; + + mockGetEnsAvatar + .mockResolvedValueOnce(testEnsAvatars[0]) + .mockResolvedValueOnce(testEnsAvatars[1]); + + const { result } = renderHook( + () => useAvatars({ ensNames: testEnsNames }, { enabled: false }), + { + wrapper: getNewReactQueryTestProvider(), + }, + ); + + expect(result.current.isLoading).toBe(false); + expect(result.current.isFetched).toBe(false); + expect(mockGetEnsAvatar).not.toHaveBeenCalled(); + }); + + it('uses the default query options when no queryOptions are provided', async () => { + const testEnsNames = ['test1.ens', 'test2.ens']; + const testEnsAvatars = ['avatarUrl1', 'avatarUrl2']; + + mockGetEnsAvatar + .mockResolvedValueOnce(testEnsAvatars[0]) + .mockResolvedValueOnce(testEnsAvatars[1]); + + renderHook(() => useAvatars({ ensNames: testEnsNames }), { + wrapper: getNewReactQueryTestProvider(), + }); + + await waitFor(() => { + expect(mockGetEnsAvatar).toHaveBeenCalled(); + }); + }); + + it('merges custom queryOptions with default options', async () => { + const testEnsNames = ['test1.ens', 'test2.ens']; + const testEnsAvatars = ['avatarUrl1', 'avatarUrl2']; + const customStaleTime = 60000; + + mockGetEnsAvatar + .mockResolvedValueOnce(testEnsAvatars[0]) + .mockResolvedValueOnce(testEnsAvatars[1]); + + const { result } = renderHook( + () => + useAvatars({ ensNames: testEnsNames }, { staleTime: customStaleTime }), + { + wrapper: getNewReactQueryTestProvider(), + }, + ); + + await waitFor(() => { + expect(result.current.data).toEqual(testEnsAvatars); + }); + + expect(mockGetEnsAvatar).toHaveBeenCalled(); + }); + + it('handles empty ensNames array', async () => { + const { result } = renderHook(() => useAvatars({ ensNames: [] }), { + wrapper: getNewReactQueryTestProvider(), + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.isFetched).toBe(false); + expect(mockGetEnsAvatar).not.toHaveBeenCalled(); + }); + + it('creates a stable query key based on ensNames', async () => { + const testEnsNames1 = ['test1.ens', 'test2.ens']; + const testEnsNames2 = ['test1.ens', 'test3.ens']; + const testEnsAvatars = ['avatarUrl1', 'avatarUrl2']; + + mockGetEnsAvatar + .mockResolvedValueOnce(testEnsAvatars[0]) + .mockResolvedValueOnce(testEnsAvatars[1]); + + const { rerender } = renderHook( + ({ ensNames }) => useAvatars({ ensNames }), + { + wrapper: getNewReactQueryTestProvider(), + initialProps: { ensNames: testEnsNames1 }, + }, + ); + + await waitFor(() => { + expect(mockGetEnsAvatar).toHaveBeenCalled(); + }); + + mockGetEnsAvatar.mockClear(); + + rerender({ ensNames: testEnsNames2 }); + + await waitFor(() => { + expect(mockGetEnsAvatar).toHaveBeenCalled(); + }); + }); + + it('handles partial failures in avatar resolution', async () => { + const testEnsNames = ['success1.eth', 'fail.eth', 'success2.eth']; + + const partialResults = ['avatar1-url', null, 'avatar2-url']; + const mockGetAvatars = vi.spyOn(getAvatarsModule, 'getAvatars'); + mockGetAvatars.mockResolvedValue(partialResults); + + const { result } = renderHook( + () => useAvatars({ ensNames: testEnsNames }), + { + wrapper: getNewReactQueryTestProvider(), + }, + ); + + await waitFor(() => { + expect(result.current.data).toEqual(partialResults); + expect(result.current.isLoading).toBe(false); + expect(result.current.isError).toBe(false); + }); + + expect(mockGetAvatars).toHaveBeenCalledWith({ + ensNames: testEnsNames, + chain: mainnet, + }); + + mockGetAvatars.mockRestore(); + }); +}); diff --git a/src/identity/hooks/useNames.test.tsx b/src/identity/hooks/useNames.test.tsx new file mode 100644 index 0000000000..511bf568ea --- /dev/null +++ b/src/identity/hooks/useNames.test.tsx @@ -0,0 +1,216 @@ +import { publicClient } from '@/core/network/client'; +import { renderHook, waitFor } from '@testing-library/react'; +import type { Address } from 'viem'; +import { base, mainnet, optimism } from 'viem/chains'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as getNamesFunctions from '../utils/getNames'; +import { getNewReactQueryTestProvider } from './getNewReactQueryTestProvider'; +import { useNames } from './useNames'; + +vi.mock('@/core/network/client'); +vi.mock('@/core/network/getChainPublicClient', () => ({ + ...vi.importActual('@/core/network/getChainPublicClient'), + getChainPublicClient: vi.fn(() => publicClient), +})); + +describe('useNames', () => { + const mockGetNames = vi.spyOn(getNamesFunctions, 'getNames'); + const testAddresses = [ + '0x1234567890123456789012345678901234567890', + '0x2345678901234567890123456789012345678901', + '0x3456789012345678901234567890123456789012', + ] as Address[]; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns the correct ENS names and loading state', async () => { + const testEnsNames = ['user1.eth', 'user2.eth', 'user3.eth']; + + mockGetNames.mockResolvedValue(testEnsNames); + + const { result } = renderHook( + () => useNames({ addresses: testAddresses }), + { + wrapper: getNewReactQueryTestProvider(), + }, + ); + + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toBe(undefined); + + await waitFor(() => { + expect(result.current.data).toEqual(testEnsNames); + expect(result.current.isLoading).toBe(false); + }); + + expect(mockGetNames).toHaveBeenCalledWith({ + addresses: testAddresses, + chain: mainnet, + }); + }); + + it('returns the correct names for custom chain', async () => { + const testBaseNames = ['user1.base', 'user2.base', 'user3.base']; + + mockGetNames.mockResolvedValue(testBaseNames); + + const { result } = renderHook( + () => + useNames({ + addresses: testAddresses, + chain: base as unknown as typeof mainnet, + }), + { + wrapper: getNewReactQueryTestProvider(), + }, + ); + + await waitFor(() => { + expect(result.current.data).toEqual(testBaseNames); + expect(result.current.isLoading).toBe(false); + }); + + expect(mockGetNames).toHaveBeenCalledWith({ + addresses: testAddresses, + chain: base, + }); + }); + + it('returns error for unsupported chain', async () => { + mockGetNames.mockRejectedValue( + 'ChainId not supported, name resolution is only supported on Ethereum and Base.', + ); + + const { result } = renderHook( + () => + useNames({ + addresses: testAddresses, + chain: optimism as unknown as typeof mainnet, + }), + { + wrapper: getNewReactQueryTestProvider(), + }, + ); + + await waitFor(() => { + expect(result.current.data).toBe(undefined); + expect(result.current.isLoading).toBe(false); + expect(result.current.isError).toBe(true); + expect(result.current.error).toBe( + 'ChainId not supported, name resolution is only supported on Ethereum and Base.', + ); + }); + }); + + it('respects the enabled option in queryOptions', async () => { + const testEnsNames = ['user1.eth', 'user2.eth', 'user3.eth']; + + mockGetNames.mockResolvedValue(testEnsNames); + + const { result } = renderHook( + () => useNames({ addresses: testAddresses }, { enabled: false }), + { + wrapper: getNewReactQueryTestProvider(), + }, + ); + + expect(result.current.isLoading).toBe(false); + expect(result.current.isFetched).toBe(false); + expect(mockGetNames).not.toHaveBeenCalled(); + }); + + it('is disabled when addresses array is empty', async () => { + const { result } = renderHook(() => useNames({ addresses: [] }), { + wrapper: getNewReactQueryTestProvider(), + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.isFetched).toBe(false); + expect(mockGetNames).not.toHaveBeenCalled(); + }); + + it('uses the default query options when no queryOptions are provided', async () => { + const testEnsNames = ['user1.eth', 'user2.eth', 'user3.eth']; + + mockGetNames.mockResolvedValue(testEnsNames); + + renderHook(() => useNames({ addresses: testAddresses }), { + wrapper: getNewReactQueryTestProvider(), + }); + + await waitFor(() => { + expect(mockGetNames).toHaveBeenCalled(); + }); + }); + + it('merges custom queryOptions with default options', async () => { + const testEnsNames = ['user1.eth', 'user2.eth', 'user3.eth']; + const customCacheTime = 120000; + + mockGetNames.mockResolvedValue(testEnsNames); + + const { result } = renderHook( + () => + useNames({ addresses: testAddresses }, { cacheTime: customCacheTime }), + { + wrapper: getNewReactQueryTestProvider(), + }, + ); + + await waitFor(() => { + expect(result.current.data).toEqual(testEnsNames); + }); + + expect(mockGetNames).toHaveBeenCalled(); + }); + + it('creates a stable query key based on addresses and chain', async () => { + const testEnsNames = ['user1.eth', 'user2.eth', 'user3.eth']; + mockGetNames.mockResolvedValue(testEnsNames); + + const { result, rerender } = renderHook( + (props: { addresses: Address[] }) => + useNames({ addresses: props.addresses }), + { + wrapper: getNewReactQueryTestProvider(), + initialProps: { addresses: testAddresses }, + }, + ); + + await waitFor(() => { + expect(result.current.data).toEqual(testEnsNames); + }); + + vi.clearAllMocks(); + + rerender({ addresses: [...testAddresses] }); + + expect(mockGetNames).not.toHaveBeenCalled(); + }); + + it('handles partial failures in name resolution', async () => { + // Mock getNames to return a mix of successful and failed resolutions + const partialResults = ['user1.eth', null, 'user3.eth']; + mockGetNames.mockResolvedValue(partialResults); + + const { result } = renderHook( + () => useNames({ addresses: testAddresses }), + { + wrapper: getNewReactQueryTestProvider(), + }, + ); + + await waitFor(() => { + expect(result.current.data).toEqual(partialResults); + expect(result.current.isLoading).toBe(false); + expect(result.current.isError).toBe(false); // Should not be in error state + }); + + expect(mockGetNames).toHaveBeenCalledWith({ + addresses: testAddresses, + chain: mainnet, + }); + }); +}); diff --git a/src/identity/utils/getAvatars.test.tsx b/src/identity/utils/getAvatars.test.tsx new file mode 100644 index 0000000000..09f7fd0040 --- /dev/null +++ b/src/identity/utils/getAvatars.test.tsx @@ -0,0 +1,348 @@ +import { base, baseSepolia, mainnet, optimism } from 'viem/chains'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { publicClient } from '../../core/network/client'; +import { getChainPublicClient } from '../../core/network/getChainPublicClient'; +import { RESOLVER_ADDRESSES_BY_CHAIN_ID } from '../constants'; +import { getAvatars } from './getAvatars'; +import { getBaseDefaultProfilePicture } from './getBaseDefaultProfilePicture'; + +vi.mock('@/core/network/client'); + +vi.mock('@/core/network/getChainPublicClient', () => ({ + ...vi.importActual('@/core/network/getChainPublicClient'), + getChainPublicClient: vi.fn(() => publicClient), +})); + +vi.mock('./getBaseDefaultProfilePicture', () => ({ + getBaseDefaultProfilePicture: vi.fn(() => 'default-base-avatar'), +})); + +describe('getAvatars', () => { + const mockGetEnsAvatar = publicClient.getEnsAvatar as Mock; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return empty array when ensNames is empty', async () => { + const result = await getAvatars({ ensNames: [] }); + expect(result).toEqual([]); + expect(mockGetEnsAvatar).not.toHaveBeenCalled(); + }); + + it('should return correct avatar URLs from client getEnsAvatar', async () => { + const ensNames = ['test1.ens', 'test2.ens']; + const expectedAvatarUrls = ['avatarUrl1', 'avatarUrl2']; + + mockGetEnsAvatar + .mockResolvedValueOnce(expectedAvatarUrls[0]) + .mockResolvedValueOnce(expectedAvatarUrls[1]); + + const avatarUrls = await getAvatars({ ensNames }); + + expect(avatarUrls).toEqual(expectedAvatarUrls); + expect(mockGetEnsAvatar).toHaveBeenCalledTimes(2); + expect(mockGetEnsAvatar).toHaveBeenNthCalledWith(1, { name: ensNames[0] }); + expect(mockGetEnsAvatar).toHaveBeenNthCalledWith(2, { name: ensNames[1] }); + expect(getChainPublicClient).toHaveBeenCalledWith(mainnet); + }); + + it('should handle null avatars correctly', async () => { + const ensNames = ['test1.ens', 'test2.ens']; + + mockGetEnsAvatar.mockResolvedValueOnce(null).mockResolvedValueOnce(null); + + const avatarUrls = await getAvatars({ ensNames }); + + expect(avatarUrls).toEqual([null, null]); + expect(mockGetEnsAvatar).toHaveBeenCalledTimes(2); + }); + + it('should resolve base mainnet avatars', async () => { + const ensNames = ['shrek.base.eth', 'donkey.base.eth']; + const expectedAvatarUrls = ['shrekface', 'donkeyface']; + + mockGetEnsAvatar + .mockResolvedValueOnce(expectedAvatarUrls[0]) + .mockResolvedValueOnce(expectedAvatarUrls[1]); + + const avatarUrls = await getAvatars({ + ensNames, + chain: base as unknown as typeof mainnet, + }); + + expect(avatarUrls).toEqual(expectedAvatarUrls); + expect(mockGetEnsAvatar).toHaveBeenCalledTimes(2); + expect(mockGetEnsAvatar).toHaveBeenNthCalledWith(1, { + name: ensNames[0], + universalResolverAddress: RESOLVER_ADDRESSES_BY_CHAIN_ID[base.id], + }); + expect(mockGetEnsAvatar).toHaveBeenNthCalledWith(2, { + name: ensNames[1], + universalResolverAddress: RESOLVER_ADDRESSES_BY_CHAIN_ID[base.id], + }); + expect(getChainPublicClient).toHaveBeenCalledWith(base); + }); + + it('should resolve base sepolia avatars', async () => { + const ensNames = ['shrek.basetest.eth', 'donkey.basetest.eth']; + const expectedAvatarUrls = ['shrektestface', 'donkeytestface']; + + mockGetEnsAvatar + .mockResolvedValueOnce(expectedAvatarUrls[0]) + .mockResolvedValueOnce(expectedAvatarUrls[1]); + + const avatarUrls = await getAvatars({ + ensNames, + chain: baseSepolia as unknown as typeof mainnet, + }); + + expect(avatarUrls).toEqual(expectedAvatarUrls); + expect(mockGetEnsAvatar).toHaveBeenCalledTimes(2); + expect(mockGetEnsAvatar).toHaveBeenNthCalledWith(1, { + name: ensNames[0], + universalResolverAddress: RESOLVER_ADDRESSES_BY_CHAIN_ID[baseSepolia.id], + }); + expect(mockGetEnsAvatar).toHaveBeenNthCalledWith(2, { + name: ensNames[1], + universalResolverAddress: RESOLVER_ADDRESSES_BY_CHAIN_ID[baseSepolia.id], + }); + expect(getChainPublicClient).toHaveBeenCalledWith(baseSepolia); + }); + + it('should default to mainnet when base mainnet avatars are not available', async () => { + const ensNames = ['shrek.base.eth', 'donkey.base.eth']; + const expectedMainnetAvatarUrls = ['mainnetshrek.eth', 'mainnetdonkey.eth']; + + mockGetEnsAvatar + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + + .mockResolvedValueOnce(expectedMainnetAvatarUrls[0]) + .mockResolvedValueOnce(expectedMainnetAvatarUrls[1]); + + const avatarUrls = await getAvatars({ + ensNames, + chain: base as unknown as typeof mainnet, + }); + + expect(avatarUrls).toEqual(expectedMainnetAvatarUrls); + + expect(mockGetEnsAvatar).toHaveBeenNthCalledWith(1, { + name: ensNames[0], + universalResolverAddress: RESOLVER_ADDRESSES_BY_CHAIN_ID[base.id], + }); + expect(mockGetEnsAvatar).toHaveBeenNthCalledWith(2, { + name: ensNames[1], + universalResolverAddress: RESOLVER_ADDRESSES_BY_CHAIN_ID[base.id], + }); + + expect(mockGetEnsAvatar).toHaveBeenNthCalledWith(3, { + name: ensNames[0], + }); + expect(mockGetEnsAvatar).toHaveBeenNthCalledWith(4, { + name: ensNames[1], + }); + + expect(getChainPublicClient).toHaveBeenNthCalledWith(1, base); + expect(getChainPublicClient).toHaveBeenNthCalledWith(2, mainnet); + }); + + it('should use default base avatar when both mainnet and base avatars are not available', async () => { + const ensNames = ['shrek.base.eth', 'regular.eth']; + + mockGetEnsAvatar.mockResolvedValue(null); + + const avatarUrls = await getAvatars({ + ensNames, + chain: base as unknown as typeof mainnet, + }); + + expect(avatarUrls).toEqual(['default-base-avatar', null]); + + expect(getBaseDefaultProfilePicture).toHaveBeenCalledWith(ensNames[0]); + expect(getBaseDefaultProfilePicture).not.toHaveBeenCalledWith(ensNames[1]); + }); + + it('should throw an error on unsupported chain', async () => { + const ensNames = ['shrek.base.eth', 'donkey.base.eth']; + + await expect( + getAvatars({ + ensNames, + chain: optimism as unknown as typeof mainnet, + }), + ).rejects.toBe( + 'ChainId not supported, avatar resolution is only supported on Ethereum and Base.', + ); + + expect(getChainPublicClient).not.toHaveBeenCalled(); + }); + + it('should handle errors when resolving base avatars and continue with mainnet', async () => { + const ensNames = ['shrek.base.eth', 'donkey.base.eth']; + const expectedMainnetAvatarUrls = ['mainnetshrek.eth', 'mainnetdonkey.eth']; + + mockGetEnsAvatar + .mockImplementationOnce(() => { + throw new Error('Base resolution error'); + }) + + .mockResolvedValueOnce(expectedMainnetAvatarUrls[0]) + .mockResolvedValueOnce(expectedMainnetAvatarUrls[1]); + + const avatarUrls = await getAvatars({ + ensNames, + chain: base as unknown as typeof mainnet, + }); + + expect(avatarUrls).toEqual(expectedMainnetAvatarUrls); + + expect(mockGetEnsAvatar).toHaveBeenNthCalledWith(2, { + name: ensNames[0], + }); + expect(mockGetEnsAvatar).toHaveBeenNthCalledWith(3, { + name: ensNames[1], + }); + }); + + it('should handle mixed basename and regular ENS names correctly', async () => { + const ensNames = ['shrek.base.eth', 'regular.eth']; + const baseAvatarUrl = 'baseavatar'; + + mockGetEnsAvatar.mockReset(); + + mockGetEnsAvatar + .mockResolvedValueOnce(baseAvatarUrl) + .mockResolvedValueOnce(null); + + const avatarUrls = await getAvatars({ + ensNames, + chain: base as unknown as typeof mainnet, + }); + + expect(avatarUrls).toEqual([baseAvatarUrl, null]); + + expect(mockGetEnsAvatar).toHaveBeenNthCalledWith(1, { + name: ensNames[0], + universalResolverAddress: RESOLVER_ADDRESSES_BY_CHAIN_ID[base.id], + }); + + expect(mockGetEnsAvatar).toHaveBeenNthCalledWith(2, { + name: ensNames[1], + }); + }); + + it('should handle errors when resolving mainnet avatars', async () => { + const ensNames = ['test1.ens', 'test2.ens']; + + mockGetEnsAvatar.mockReset(); + + mockGetEnsAvatar.mockImplementationOnce(() => { + throw new Error('Mainnet resolution error'); + }); + + const avatarUrls = await getAvatars({ ensNames }); + + expect(avatarUrls).toEqual([null, null]); + + expect(mockGetEnsAvatar).toHaveBeenCalledWith({ + name: ensNames[0], + }); + }); + + it('should handle partial failures in batch avatar resolution', async () => { + const ensNames = ['success1.eth', 'fail.eth', 'success2.eth']; + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + mockGetEnsAvatar.mockReset(); + mockGetEnsAvatar.mockImplementation((params) => { + if (params.name === 'fail.eth') { + return Promise.reject( + new Error('Avatar resolution failed for this name'), + ); + } else if (params.name === 'success1.eth') { + return Promise.resolve('avatar1-url'); + } else if (params.name === 'success2.eth') { + return Promise.resolve('avatar2-url'); + } + return Promise.resolve(null); + }); + + const avatarUrls = await getAvatars({ ensNames }); + + // With the improved implementation, successful resolutions should work + // even when some addresses fail + expect(avatarUrls).toEqual(['avatar1-url', null, 'avatar2-url']); + expect(mockGetEnsAvatar).toHaveBeenCalledTimes(3); + expect(consoleSpy).toHaveBeenCalledWith( + 'Error resolving ENS avatar for fail.eth:', + expect.any(Error), + ); + + consoleSpy.mockRestore(); + }); + + it('should handle partial failures in batch Base avatar resolution', async () => { + const ensNames = [ + 'success1.base.eth', + 'fail.base.eth', + 'success2.base.eth', + ]; + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + mockGetEnsAvatar.mockReset(); + mockGetEnsAvatar.mockImplementation((params) => { + // Base resolution + if (params.universalResolverAddress) { + if (params.name === 'fail.base.eth') { + return Promise.reject(new Error('Base avatar resolution failed')); + } else if (params.name === 'success1.base.eth') { + return Promise.resolve('base-avatar1-url'); + } else if (params.name === 'success2.base.eth') { + return Promise.resolve('base-avatar2-url'); + } + } + // Mainnet fallback (shouldn't be called for successful Base resolutions) + return Promise.resolve(null); + }); + + const avatarUrls = await getAvatars({ + ensNames, + chain: base as unknown as typeof mainnet, + }); + + // With the improved implementation, successful Base resolutions should work + // even when some addresses fail + expect(avatarUrls).toEqual([ + 'base-avatar1-url', + 'default-base-avatar', + 'base-avatar2-url', + ]); + + // Check that Base resolution was attempted for all names + expect(mockGetEnsAvatar).toHaveBeenCalledWith({ + name: 'success1.base.eth', + universalResolverAddress: RESOLVER_ADDRESSES_BY_CHAIN_ID[base.id], + }); + expect(mockGetEnsAvatar).toHaveBeenCalledWith({ + name: 'fail.base.eth', + universalResolverAddress: RESOLVER_ADDRESSES_BY_CHAIN_ID[base.id], + }); + expect(mockGetEnsAvatar).toHaveBeenCalledWith({ + name: 'success2.base.eth', + universalResolverAddress: RESOLVER_ADDRESSES_BY_CHAIN_ID[base.id], + }); + + // Check that the error was logged + expect(consoleSpy).toHaveBeenCalledWith( + 'Error resolving Base avatar for fail.base.eth:', + expect.any(Error), + ); + + // Check that default Base profile picture was used for the failed name + expect(getBaseDefaultProfilePicture).toHaveBeenCalledWith('fail.base.eth'); + + consoleSpy.mockRestore(); + }); +}); diff --git a/src/identity/utils/getAvatars.ts b/src/identity/utils/getAvatars.ts index c264dc3073..a3673b2020 100644 --- a/src/identity/utils/getAvatars.ts +++ b/src/identity/utils/getAvatars.ts @@ -21,7 +21,8 @@ export type GetAvatars = { export const getAvatars = async ({ ensNames, chain = mainnet, -}: GetAvatars): Promise => { +}: // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: ignore +GetAvatars): Promise => { if (!ensNames || ensNames.length === 0) { return []; } @@ -56,12 +57,20 @@ export const getAvatars = async ({ const client = getChainPublicClient(chain); try { - // Create batch of calls for Base avatars + // Create batch of calls for Base avatars with individual error handling const baseAvatarPromises = basenameIndices.map((index) => - client.getEnsAvatar({ - name: normalize(ensNames[index]), - universalResolverAddress: RESOLVER_ADDRESSES_BY_CHAIN_ID[chain.id], - }), + client + .getEnsAvatar({ + name: normalize(ensNames[index]), + universalResolverAddress: RESOLVER_ADDRESSES_BY_CHAIN_ID[chain.id], + }) + .catch((error) => { + console.error( + `Error resolving Base avatar for ${ensNames[index]}:`, + error, + ); + return null; // Return null for failed resolutions + }), ); // Execute all Base avatar resolution calls @@ -75,7 +84,7 @@ export const getAvatars = async ({ } }); } catch (error) { - // This is a best effort attempt, so we continue to fallback + // This should never happen now, but keeping as a safeguard console.error('Error resolving Base avatars in batch:', error); } } @@ -85,15 +94,20 @@ export const getAvatars = async ({ // For all names, try mainnet resolution try { - // Create batch of ENS avatar resolution calls + // Create batch of ENS avatar resolution calls with individual error handling const ensAvatarPromises = ensNames.map((name, index) => { // Skip if we already have a result if (results[index] !== null) { return Promise.resolve(null); } - return fallbackClient.getEnsAvatar({ - name: normalize(name), - }); + return fallbackClient + .getEnsAvatar({ + name: normalize(name), + }) + .catch((error) => { + console.error(`Error resolving ENS avatar for ${name}:`, error); + return null; // Return null for failed resolutions + }); }); // Execute all ENS avatar resolution calls @@ -106,17 +120,18 @@ export const getAvatars = async ({ } }); } catch (error) { + // This should never happen now, but keeping as a safeguard console.error('Error resolving ENS avatars in batch:', error); } // Apply default Base profile pictures for basenames that don't have avatars - basenameIndices.forEach((index) => { + for (const index of basenameIndices) { if (results[index] === null) { results[index] = getBaseDefaultProfilePicture( ensNames[index] as Basename, ); } - }); + } return results; }; diff --git a/src/identity/utils/getNames.test.ts b/src/identity/utils/getNames.test.ts new file mode 100644 index 0000000000..bc6a432659 --- /dev/null +++ b/src/identity/utils/getNames.test.ts @@ -0,0 +1,184 @@ +import { publicClient } from '@/core/network/client'; +import type { Address } from 'viem'; +import { base, type mainnet, optimism } from 'viem/chains'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Mock } from 'vitest'; +import L2ResolverAbi from '../abis/L2ResolverAbi'; +import { RESOLVER_ADDRESSES_BY_CHAIN_ID } from '../constants'; +import { convertReverseNodeToBytes } from './convertReverseNodeToBytes'; +import { getNames } from './getNames'; + +vi.mock('@/core/network/client'); + +vi.mock('@/core/utils/getSlicedAddress', () => ({ + getSlicedAddress: vi.fn(), +})); + +vi.mock('@/core/network/getChainPublicClient', () => ({ + ...vi.importActual('@/core/network/getChainPublicClient'), + getChainPublicClient: vi.fn(() => publicClient), +})); + +vi.mock('./convertReverseNodeToBytes', () => ({ + convertReverseNodeToBytes: vi.fn((address) => `${address}-bytes`), +})); + +describe('getNames', () => { + const mockGetEnsName = publicClient.getEnsName as Mock; + const mockMulticall = publicClient.multicall as Mock; + const walletAddresses = [ + '0x1234567890123456789012345678901234567890', + '0x2345678901234567890123456789012345678901', + '0x3456789012345678901234567890123456789012', + ] as Address[]; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return empty array when no addresses are provided', async () => { + const names = await getNames({ addresses: [] }); + expect(names).toEqual([]); + }); + + it('should fetch ENS names for multiple addresses on mainnet', async () => { + const expectedEnsNames = ['user1.eth', 'user2.eth', null]; + mockGetEnsName.mockImplementation((params) => { + const index = walletAddresses.findIndex( + (addr) => addr === params.address, + ); + return Promise.resolve(expectedEnsNames[index]); + }); + + const names = await getNames({ addresses: walletAddresses }); + + expect(names).toEqual(expectedEnsNames); + expect(mockGetEnsName).toHaveBeenCalledTimes(3); + walletAddresses.forEach((address, index) => { + expect(mockGetEnsName).toHaveBeenNthCalledWith(index + 1, { address }); + }); + }); + + it('should fetch Basenames for multiple addresses on Base chain', async () => { + const expectedBaseNames = ['user1.base', 'user2.base', 'user3.base']; + + mockMulticall.mockResolvedValue( + expectedBaseNames.map((name) => ({ + status: 'success', + result: name, + })), + ); + + const names = await getNames({ + addresses: walletAddresses, + chain: base as unknown as typeof mainnet, + }); + + expect(names).toEqual(expectedBaseNames); + expect(mockMulticall).toHaveBeenCalledTimes(1); + expect(mockMulticall).toHaveBeenCalledWith({ + contracts: walletAddresses.map((address) => ({ + address: RESOLVER_ADDRESSES_BY_CHAIN_ID[base.id], + abi: L2ResolverAbi, + functionName: 'name', + args: [convertReverseNodeToBytes(address, base.id)], + })), + allowFailure: true, + }); + }); + + it('should fall back to ENS resolution for unresolved Base names', async () => { + const baseResults = [ + { status: 'success', result: 'user1.base' }, + { status: 'success', result: 'user2.base' }, + { status: 'failure', result: null }, + ]; + + mockMulticall.mockResolvedValue(baseResults); + + mockGetEnsName.mockImplementation((params) => { + if (params.address === walletAddresses[2]) { + return Promise.resolve('user3.eth'); + } + return Promise.resolve(null); + }); + + const names = await getNames({ + addresses: walletAddresses, + chain: base as unknown as typeof mainnet, + }); + + expect(names).toEqual(['user1.base', 'user2.base', 'user3.eth']); + expect(mockMulticall).toHaveBeenCalledTimes(1); + expect(mockGetEnsName).toHaveBeenCalledTimes(1); + expect(mockGetEnsName).toHaveBeenCalledWith({ + address: walletAddresses[2], + }); + }); + + it('should handle multicall errors gracefully and fall back to ENS', async () => { + mockMulticall.mockRejectedValue(new Error('Multicall failed')); + + const expectedEnsNames = ['user1.eth', 'user2.eth', 'user3.eth']; + mockGetEnsName.mockImplementation((params) => { + const index = walletAddresses.findIndex( + (addr) => addr === params.address, + ); + return Promise.resolve(expectedEnsNames[index]); + }); + + const names = await getNames({ + addresses: walletAddresses, + chain: base as unknown as typeof mainnet, + }); + + expect(names).toEqual(expectedEnsNames); + expect(mockMulticall).toHaveBeenCalledTimes(1); + expect(mockGetEnsName).toHaveBeenCalledTimes(3); + }); + + it('should throw an error for unsupported chains', async () => { + await expect( + getNames({ + addresses: walletAddresses, + chain: optimism as unknown as typeof mainnet, + }), + ).rejects.toBe( + 'ChainId not supported, name resolution is only supported on Ethereum and Base.', + ); + + expect(mockGetEnsName).not.toHaveBeenCalled(); + expect(mockMulticall).not.toHaveBeenCalled(); + }); + + it('should handle ENS resolution errors gracefully', async () => { + mockGetEnsName.mockRejectedValue(new Error('ENS resolution failed')); + + const names = await getNames({ addresses: walletAddresses }); + + expect(names).toEqual([null, null, null]); + expect(mockGetEnsName).toHaveBeenCalledTimes(3); + }); + + it('should handle partial ENS resolution failures', async () => { + mockGetEnsName.mockImplementation((params) => { + if (params.address === walletAddresses[0]) { + return Promise.resolve('user1.eth'); + } else if (params.address === walletAddresses[1]) { + return Promise.reject(new Error('ENS resolution failed')); + } else { + return Promise.resolve('user3.eth'); + } + }); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const names = await getNames({ addresses: walletAddresses }); + + expect(names).toEqual(['user1.eth', null, 'user3.eth']); + expect(mockGetEnsName).toHaveBeenCalledTimes(3); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); +}); diff --git a/src/identity/utils/getNames.ts b/src/identity/utils/getNames.ts index 85a6117f8c..724d28b44e 100644 --- a/src/identity/utils/getNames.ts +++ b/src/identity/utils/getNames.ts @@ -21,6 +21,7 @@ export type GetNames = { export const getNames = async ({ addresses, chain = mainnet, + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: ignore }: GetNames): Promise => { if (!addresses || addresses.length === 0) { return []; @@ -84,14 +85,22 @@ export const getNames = async ({ if (unresolvedIndices.length > 0) { try { - // Create batch of ENS resolution calls + // Create batch of ENS resolution calls with error handling for each promise const ensPromises = unresolvedIndices.map((index) => - fallbackClient.getEnsName({ - address: addresses[index], - }), + fallbackClient + .getEnsName({ + address: addresses[index], + }) + .catch((error) => { + console.error( + `Error resolving ENS name for ${addresses[index]}:`, + error, + ); + return null; // Return null for failed resolutions + }), ); - // Execute all ENS resolution calls + // Execute all ENS resolution calls - this won't fail even if individual promises reject const ensResults = await Promise.all(ensPromises); // Update results with ENS names @@ -100,6 +109,7 @@ export const getNames = async ({ results[originalIndex] = ensName; }); } catch (error) { + // This should never happen now, but keeping as a safeguard console.error('Error resolving ENS names in batch:', error); } }