diff --git a/src/identity/hooks/useAvatars.test.tsx b/src/identity/hooks/useAvatars.test.tsx new file mode 100644 index 0000000000..4040a21cce --- /dev/null +++ b/src/identity/hooks/useAvatars.test.tsx @@ -0,0 +1,238 @@ +import { publicClient } from '@/core/network/client'; +import { getChainPublicClient } from '@/core/network/getChainPublicClient'; +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(); + }); + }); +}); diff --git a/src/identity/utils/getAvatars.test.tsx b/src/identity/utils/getAvatars.test.tsx new file mode 100644 index 0000000000..9d3c358b29 --- /dev/null +++ b/src/identity/utils/getAvatars.test.tsx @@ -0,0 +1,253 @@ +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], + }); + }); +}); diff --git a/src/identity/utils/getAvatars.ts b/src/identity/utils/getAvatars.ts index 5f628c1c3c..26f6dde915 100644 --- a/src/identity/utils/getAvatars.ts +++ b/src/identity/utils/getAvatars.ts @@ -18,6 +18,7 @@ export type GetAvatars = { * avatars for a given array of ENS names in a single batch request. * It returns an array of avatar URLs in the same order as the input names. */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: ignore export const getAvatars = async ({ ensNames, chain = mainnet,