From 6878e46bbd2f2c9f9fd9d1a247c4c9e4d28796df Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Thu, 30 May 2024 14:05:10 -0600 Subject: [PATCH 1/4] feat: Test setup Added better test setup Added jest setup Added tests for wallet services --- jest.config.js | 1 + jest.setup.js | 3 + src/polyfills.ts | 2 + src/services/coinbaseWallet.test.ts | 132 ++++++++++++++++++++++++++++ src/services/coinbaseWallet.ts | 2 +- src/services/randomWallet.test.ts | 72 +++++++++++++++ src/services/randomWallet.ts | 2 +- 7 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 jest.setup.js create mode 100644 src/services/coinbaseWallet.test.ts create mode 100644 src/services/randomWallet.test.ts diff --git a/jest.config.js b/jest.config.js index 8439ec9..ad241f8 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,4 +4,5 @@ module.exports = { preset: 'react-native', transformIgnorePatterns: [`node_modules/(?!${esModules})`], testPathIgnorePatterns: ['/node_modules/', '/e2e/'], + setupFiles: ['./jest.setup.js'], }; diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000..1d2de29 --- /dev/null +++ b/jest.setup.js @@ -0,0 +1,3 @@ +import 'fastestsmallesttextencoderdecoder'; +import 'react-native-get-random-values'; +import 'react-native-url-polyfill/auto'; diff --git a/src/polyfills.ts b/src/polyfills.ts index 1d2de29..15672f6 100644 --- a/src/polyfills.ts +++ b/src/polyfills.ts @@ -1,3 +1,5 @@ import 'fastestsmallesttextencoderdecoder'; import 'react-native-get-random-values'; import 'react-native-url-polyfill/auto'; + +// Make sure to add new polyfills to jest.setup.js as well diff --git a/src/services/coinbaseWallet.test.ts b/src/services/coinbaseWallet.test.ts new file mode 100644 index 0000000..d150997 --- /dev/null +++ b/src/services/coinbaseWallet.test.ts @@ -0,0 +1,132 @@ +import {configure, isConnected} from '@coinbase/wallet-mobile-sdk'; +import {Alert} from 'react-native'; +import {CoinbaseWallet} from './CoinbaseWallet'; +import {mmkvStorage} from './mmkvStorage'; + +// Mock mmkvStorage +jest.mock('./mmkvStorage', () => ({ + mmkvStorage: { + saveAddress: jest.fn(), + }, +})); + +// Mock @coinbase/wallet-mobile-sdk +jest.mock('@coinbase/wallet-mobile-sdk', () => ({ + configure: jest.fn(), + handleResponse: jest.fn(), + isConnected: jest.fn(), + WalletMobileSDKEVMProvider: jest.fn().mockImplementation(() => ({ + request: jest.fn(), + disconnect: jest.fn(), + })), +})); + +// Mock react-native +jest.mock('react-native', () => ({ + Alert: { + alert: jest.fn(), + }, + Linking: { + addEventListener: jest.fn(() => ({ + remove: jest.fn(), + })), + }, +})); + +describe('CoinbaseWallet', () => { + let coinbaseWallet: CoinbaseWallet; + + beforeEach(() => { + coinbaseWallet = new CoinbaseWallet(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should configure the Coinbase Wallet SDK and initialize provider', () => { + expect(configure).toHaveBeenCalledWith({ + hostURL: new URL('https://wallet.coinbase.com/wsegue'), + callbackURL: new URL('ephemera-chat://'), + hostPackageName: 'org.toshi', + }); + expect(coinbaseWallet.provider).toBeDefined(); + }); + + test('connect should request accounts and save the address', async () => { + const mockAddress = '0x1234567890abcdef1234567890abcdef12345678'; + // @ts-ignore-next-line + coinbaseWallet.provider.request.mockResolvedValueOnce([mockAddress]); + await coinbaseWallet.connect(); + + expect(coinbaseWallet.address).toBe(mockAddress); + expect(mmkvStorage.saveAddress).toHaveBeenCalledWith(mockAddress); + }); + + test('disconnect should call provider.disconnect', async () => { + await coinbaseWallet.disconnect(); + expect(coinbaseWallet.provider.disconnect).toHaveBeenCalled(); + }); + + test('isAvailableToConnect should return true', async () => { + const result = await coinbaseWallet.isAvailableToConnect(); + expect(result).toBe(true); + }); + + test('isConnected should return true if address is set', async () => { + const mockAddress = '0x1234567890abcdef1234567890abcdef12345678'; + coinbaseWallet.address = mockAddress; + // @ts-ignore-next-line + + isConnected.mockResolvedValueOnce(true); + + const result = await coinbaseWallet.isConnected(); + expect(result).toBe(true); + }); + + test('getAddress should return the address if set', async () => { + const mockAddress = '0x1234567890abcdef1234567890abcdef12345678'; + coinbaseWallet.address = mockAddress; + const address = await coinbaseWallet.getAddress(); + expect(address).toBe(mockAddress); + }); + + test('getAddress should call connect if address is not set', async () => { + const mockAddress = '0x1234567890abcdef1234567890abcdef12345678'; + // @ts-ignore-next-line + + coinbaseWallet.provider.request.mockResolvedValueOnce([mockAddress]); + + const address = await coinbaseWallet.getAddress(); + expect(address).toBe(mockAddress); + }); + + test('signMessage should return a signed message', async () => { + const message = 'Test message'; + const signedMessage = 'signed_message'; + // @ts-ignore-next-line + + coinbaseWallet.provider.request.mockResolvedValueOnce(signedMessage); + + const result = await coinbaseWallet.signMessage(message); + expect(result).toBe(signedMessage); + }); + + test('signMessage should throw an error if signing fails', async () => { + const message = 'Test message'; + const errorMessage = 'Error signing message'; + // @ts-ignore-next-line + + coinbaseWallet.provider.request.mockRejectedValueOnce( + new Error(errorMessage), + ); + + await expect(coinbaseWallet.signMessage(message)).rejects.toThrow( + errorMessage, + ); + expect(Alert.alert).toHaveBeenCalledWith( + 'Error signing message', + errorMessage, + ); + }); +}); diff --git a/src/services/coinbaseWallet.ts b/src/services/coinbaseWallet.ts index c891c26..4b49ec6 100644 --- a/src/services/coinbaseWallet.ts +++ b/src/services/coinbaseWallet.ts @@ -10,7 +10,7 @@ import {WalletConnection} from '../models/WalletConnection'; import {createDeepLink} from '../navigation/linkingDefinition'; import {mmkvStorage} from './mmkvStorage'; -class CoinbaseWallet extends WalletConnection { +export class CoinbaseWallet extends WalletConnection { provider: WalletMobileSDKEVMProvider; address?: string; signer: Signer; diff --git a/src/services/randomWallet.test.ts b/src/services/randomWallet.test.ts new file mode 100644 index 0000000..4cad5f4 --- /dev/null +++ b/src/services/randomWallet.test.ts @@ -0,0 +1,72 @@ +import {mmkvStorage} from './mmkvStorage'; +import {RandomWallet} from './RandomWallet'; + +// Mock mmkvStorage +jest.mock('./mmkvStorage', () => ({ + mmkvStorage: { + saveAddress: jest.fn(), + }, +})); + +describe('RandomWallet', () => { + let randomWallet: RandomWallet; + + beforeEach(() => { + randomWallet = new RandomWallet(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should generate a private key and set account and address', () => { + expect(randomWallet.account).toBeDefined(); + expect(randomWallet.address).toBe(randomWallet.account.address); + mmkvStorage.saveAddress.mockReturnValue(); + expect(mmkvStorage.saveAddress).toHaveBeenCalledWith(randomWallet.address); + }); + + test('isAvailableToConnect should return true', async () => { + const result = await randomWallet.isAvailableToConnect(); + expect(result).toBe(true); + }); + + test('isConnected should return true when address is set', async () => { + const result = await randomWallet.isConnected(); + expect(result).toBe(true); + }); + + test('getAddress should return the address if set', async () => { + const address = await randomWallet.getAddress(); + expect(address).toBe(randomWallet.account.address); + }); + + test('getAddress should throw an error if address is not set', async () => { + randomWallet.address = undefined; + await expect(randomWallet.getAddress()).rejects.toThrow( + 'Failed to get address', + ); + }); + + test('signMessage should return a signed message', async () => { + const message = 'Test message'; + const signedMessage = 'signed_message'; // Mocked signed message + + randomWallet.account.signMessage = jest + .fn() + .mockResolvedValue(signedMessage); + + const result = await randomWallet.signMessage(message); + expect(result).toBe(signedMessage); + expect(randomWallet.account.signMessage).toHaveBeenCalledWith({message}); + }); + + test('signMessage should throw an error if account does not have signMessage function', async () => { + // @ts-expect-error + randomWallet.account.signMessage = undefined; + + await expect(randomWallet.signMessage('Test message')).rejects.toThrow( + 'Failed to sign message', + ); + }); +}); diff --git a/src/services/randomWallet.ts b/src/services/randomWallet.ts index 6a3140a..acf3d79 100644 --- a/src/services/randomWallet.ts +++ b/src/services/randomWallet.ts @@ -7,7 +7,7 @@ import { import {WalletConnection} from '../models/WalletConnection'; import {mmkvStorage} from './mmkvStorage'; -class RandomWallet extends WalletConnection { +export class RandomWallet extends WalletConnection { address?: string; account: PrivateKeyAccount; From c9607d20567418eaffcd792066cbcae0740eec45 Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Thu, 30 May 2024 14:18:54 -0600 Subject: [PATCH 2/4] fix tsc --- src/services/randomWallet.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/services/randomWallet.test.ts b/src/services/randomWallet.test.ts index 4cad5f4..7627462 100644 --- a/src/services/randomWallet.test.ts +++ b/src/services/randomWallet.test.ts @@ -22,6 +22,7 @@ describe('RandomWallet', () => { test('should generate a private key and set account and address', () => { expect(randomWallet.account).toBeDefined(); expect(randomWallet.address).toBe(randomWallet.account.address); + // @ts-ignore-next-line mmkvStorage.saveAddress.mockReturnValue(); expect(mmkvStorage.saveAddress).toHaveBeenCalledWith(randomWallet.address); }); From ea02aa26dac502e63cad901cae51baceaad64f9e Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Thu, 30 May 2024 14:32:13 -0600 Subject: [PATCH 3/4] fixed tests Updated usage to be in connect method Fixed file names --- src/services/coinbaseWallet.test.ts | 2 +- src/services/randomWallet.test.ts | 22 ++++++++++++++-------- src/services/randomWallet.ts | 15 +++++++++------ 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/services/coinbaseWallet.test.ts b/src/services/coinbaseWallet.test.ts index d150997..f755a3b 100644 --- a/src/services/coinbaseWallet.test.ts +++ b/src/services/coinbaseWallet.test.ts @@ -1,6 +1,6 @@ import {configure, isConnected} from '@coinbase/wallet-mobile-sdk'; import {Alert} from 'react-native'; -import {CoinbaseWallet} from './CoinbaseWallet'; +import {CoinbaseWallet} from './coinbaseWallet'; import {mmkvStorage} from './mmkvStorage'; // Mock mmkvStorage diff --git a/src/services/randomWallet.test.ts b/src/services/randomWallet.test.ts index 7627462..008b28c 100644 --- a/src/services/randomWallet.test.ts +++ b/src/services/randomWallet.test.ts @@ -1,5 +1,5 @@ import {mmkvStorage} from './mmkvStorage'; -import {RandomWallet} from './RandomWallet'; +import {RandomWallet} from './randomWallet'; // Mock mmkvStorage jest.mock('./mmkvStorage', () => ({ @@ -19,9 +19,10 @@ describe('RandomWallet', () => { jest.clearAllMocks(); }); - test('should generate a private key and set account and address', () => { + test('should generate a private key and set account and address', async () => { + await randomWallet.connect(); expect(randomWallet.account).toBeDefined(); - expect(randomWallet.address).toBe(randomWallet.account.address); + expect(randomWallet.address).toBe(randomWallet.account?.address); // @ts-ignore-next-line mmkvStorage.saveAddress.mockReturnValue(); expect(mmkvStorage.saveAddress).toHaveBeenCalledWith(randomWallet.address); @@ -33,17 +34,21 @@ describe('RandomWallet', () => { }); test('isConnected should return true when address is set', async () => { + await randomWallet.connect(); const result = await randomWallet.isConnected(); expect(result).toBe(true); }); test('getAddress should return the address if set', async () => { const address = await randomWallet.getAddress(); - expect(address).toBe(randomWallet.account.address); + expect(address).toBe(randomWallet.account?.address); }); test('getAddress should throw an error if address is not set', async () => { - randomWallet.address = undefined; + randomWallet.account = { + // @ts-ignore-next-line + address: undefined, + }; await expect(randomWallet.getAddress()).rejects.toThrow( 'Failed to get address', ); @@ -52,19 +57,20 @@ describe('RandomWallet', () => { test('signMessage should return a signed message', async () => { const message = 'Test message'; const signedMessage = 'signed_message'; // Mocked signed message - + await randomWallet.connect(); + // @ts-ignore-next-line randomWallet.account.signMessage = jest .fn() .mockResolvedValue(signedMessage); const result = await randomWallet.signMessage(message); expect(result).toBe(signedMessage); + // @ts-ignore-next-line expect(randomWallet.account.signMessage).toHaveBeenCalledWith({message}); }); test('signMessage should throw an error if account does not have signMessage function', async () => { - // @ts-expect-error - randomWallet.account.signMessage = undefined; + randomWallet.account = undefined; await expect(randomWallet.signMessage('Test message')).rejects.toThrow( 'Failed to sign message', diff --git a/src/services/randomWallet.ts b/src/services/randomWallet.ts index acf3d79..2356714 100644 --- a/src/services/randomWallet.ts +++ b/src/services/randomWallet.ts @@ -9,10 +9,14 @@ import {mmkvStorage} from './mmkvStorage'; export class RandomWallet extends WalletConnection { address?: string; - account: PrivateKeyAccount; + account?: PrivateKeyAccount; constructor() { super(); + } + + async connect() { + // const privateKey = generatePrivateKey(); const account = privateKeyToAccount(privateKey); this.account = account; @@ -20,10 +24,6 @@ export class RandomWallet extends WalletConnection { mmkvStorage.saveAddress(this.address); } - async connect() { - // - } - async disconnect() { // } @@ -37,7 +37,10 @@ export class RandomWallet extends WalletConnection { } async getAddress() { - if (!this.address) { + if (!this.account) { + await this.connect(); + } + if (!this.account?.address) { throw new Error('Failed to get address'); } return this.account.address; From ee012a258d7feb22b98de9a89ac02b8092941b80 Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Thu, 30 May 2024 15:21:32 -0600 Subject: [PATCH 4/4] test setup Added hook tests Added mock for XMTP --- package.json | 2 + src/__mocks__/@xmtp/react-native-sdk.ts | 33 +++++++ src/hooks/useAddress.test.ts | 41 ++++++++ src/hooks/useClient.test.tsx | 35 +++++++ src/hooks/useContactInfo.test.ts | 124 ++++++++++++++++++++++++ src/hooks/useDebounce.test.tsx | 98 +++++++++++++++++++ src/hooks/useEnsAddress.test.tsx | 62 ++++++++++++ src/hooks/useEnsAddress.ts | 1 + src/hooks/useGroupMessages.test.ts | 101 +++++++++++++++++++ yarn.lock | 54 ++++++++++- 10 files changed, 547 insertions(+), 4 deletions(-) create mode 100644 src/__mocks__/@xmtp/react-native-sdk.ts create mode 100644 src/hooks/useAddress.test.ts create mode 100644 src/hooks/useClient.test.tsx create mode 100644 src/hooks/useContactInfo.test.ts create mode 100644 src/hooks/useDebounce.test.tsx create mode 100644 src/hooks/useEnsAddress.test.tsx create mode 100644 src/hooks/useGroupMessages.test.ts diff --git a/package.json b/package.json index 6c53231..cd40931 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,8 @@ "@react-native/eslint-config": "^0.73.1", "@react-native/metro-config": "^0.73.2", "@react-native/typescript-config": "^0.73.1", + "@testing-library/jest-native": "^5.4.3", + "@testing-library/react-hooks": "^8.0.1", "@types/jest": "^29.5.11", "@types/react": "^18.2.6", "@types/react-native-push-notification": "^8.1.4", diff --git a/src/__mocks__/@xmtp/react-native-sdk.ts b/src/__mocks__/@xmtp/react-native-sdk.ts new file mode 100644 index 0000000..ed19fe3 --- /dev/null +++ b/src/__mocks__/@xmtp/react-native-sdk.ts @@ -0,0 +1,33 @@ +function mockedClient() { + return { + conversations: { + streamAllMessages: jest.fn(() => {}), + cancelStreamAllMessages: jest.fn(() => {}), + cancelStream: jest.fn(() => {}), + }, + exportKeyBundle: jest.fn(() => 'keybundle'), + canMessage: jest.fn(() => true), + }; +} + +module.exports = { + Client: { + createFromKeyBundle: jest.fn().mockImplementation(mockedClient), + createRandom: jest.fn().mockImplementation(mockedClient), + }, + StaticAttachmentCodec: jest.fn().mockImplementation(() => { + return {}; + }), + RemoteAttachmentCodec: jest.fn().mockImplementation(() => { + return {}; + }), + JSContentCodec: jest.fn().mockImplementation(() => { + return {}; + }), + GroupChangeCodec: jest.fn().mockImplementation(() => { + return {}; + }), + ReplyCodec: jest.fn().mockImplementation(() => ({})), + ReactionCodec: jest.fn().mockImplementation(() => ({})), + emitter: {removeAllListeners: jest.fn(() => {})}, +}; diff --git a/src/hooks/useAddress.test.ts b/src/hooks/useAddress.test.ts new file mode 100644 index 0000000..4e28d6f --- /dev/null +++ b/src/hooks/useAddress.test.ts @@ -0,0 +1,41 @@ +import {renderHook} from '@testing-library/react-hooks'; +import {useAddress} from './useAddress'; +import {useClient} from './useClient'; + +jest.mock('./useClient'); + +describe('useAddress', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should return address and loading state from useClient', () => { + const mockClient = {address: '0x1234567890abcdef1234567890abcdef12345678'}; + const mockLoading = false; + + (useClient as jest.Mock).mockReturnValue({ + client: mockClient, + loading: mockLoading, + }); + + const {result} = renderHook(() => useAddress()); + + expect(result.current.address).toBe(mockClient.address); + expect(result.current.loading).toBe(mockLoading); + }); + + test('should return undefined address if client is not available', () => { + const mockClient = null; + const mockLoading = true; + + (useClient as jest.Mock).mockReturnValue({ + client: mockClient, + loading: mockLoading, + }); + + const {result} = renderHook(() => useAddress()); + + expect(result.current.address).toBeUndefined(); + expect(result.current.loading).toBe(mockLoading); + }); +}); diff --git a/src/hooks/useClient.test.tsx b/src/hooks/useClient.test.tsx new file mode 100644 index 0000000..b008b1b --- /dev/null +++ b/src/hooks/useClient.test.tsx @@ -0,0 +1,35 @@ +import {renderHook} from '@testing-library/react-hooks'; +import React from 'react'; +import {ClientContext} from '../context/ClientContext'; +import {useClient} from './useClient'; + +describe('useClient', () => { + test('should return the client context value', () => { + const mockContext = { + client: null, + setClient: () => {}, + loading: false, + }; + + const wrapper = ({children}: {children: React.ReactNode}) => ( + + {children} + + ); + + const {result} = renderHook(() => useClient(), {wrapper}); + + expect(result.current).toBe(mockContext); + }); + + test('should return null if no client context value is provided', () => { + const wrapper = ({children}: {children: React.ReactNode}) => ( + // @ts-ignore-next-line + {children} + ); + + const {result} = renderHook(() => useClient(), {wrapper}); + + expect(result.current).toBeNull(); + }); +}); diff --git a/src/hooks/useContactInfo.test.ts b/src/hooks/useContactInfo.test.ts new file mode 100644 index 0000000..f8f69d4 --- /dev/null +++ b/src/hooks/useContactInfo.test.ts @@ -0,0 +1,124 @@ +import {act, renderHook} from '@testing-library/react-hooks'; +import {mmkvStorage} from '../services/mmkvStorage'; +import {getEnsInfo} from '../utils/getEnsInfo'; +import {useContactInfo} from './useContactInfo'; + +// Mock dependencies +jest.mock('../services/mmkvStorage', () => ({ + mmkvStorage: { + getEnsName: jest.fn(), + getEnsAvatar: jest.fn(), + saveEnsName: jest.fn(), + saveEnsAvatar: jest.fn(), + clearEnsAvatar: jest.fn(), + }, +})); + +jest.mock('../utils/formatAddress', () => ({ + formatAddress: jest.fn(address => `Formatted: ${address}`), +})); + +jest.mock('../utils/getEnsInfo', () => ({ + getEnsInfo: jest.fn(), +})); + +describe('useContactInfo', () => { + const address = '0x1234567890abcdef1234567890abcdef12345678'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should return initial state when no address is provided', () => { + const {result} = renderHook(() => useContactInfo()); + + expect(result.current).toEqual({ + displayName: null, + avatarUrl: null, + loading: true, + }); + }); + + test('should return cached ENS name and avatar if available', () => { + (mmkvStorage.getEnsName as jest.Mock).mockReturnValue('cachedName'); + (mmkvStorage.getEnsAvatar as jest.Mock).mockReturnValue('cachedAvatarUrl'); + (getEnsInfo as jest.Mock).mockRejectedValueOnce('Failed to fetch ENS info'); + + const {result} = renderHook(() => useContactInfo(address)); + + expect(result.current).toEqual({ + displayName: 'cachedName', + avatarUrl: 'cachedAvatarUrl', + loading: true, + }); + }); + + test('should fetch ENS info and update state', async () => { + (mmkvStorage.getEnsName as jest.Mock).mockReturnValue(null); + (mmkvStorage.getEnsAvatar as jest.Mock).mockReturnValue(null); + (getEnsInfo as jest.Mock).mockResolvedValue({ + ens: 'ensName', + avatarUrl: 'ensAvatarUrl', + }); + + const {result, waitForNextUpdate} = renderHook(() => + useContactInfo(address), + ); + + await act(async () => { + await waitForNextUpdate(); + }); + + expect(getEnsInfo).toHaveBeenCalledWith(address); + expect(mmkvStorage.saveEnsName).toHaveBeenCalledWith(address, 'ensName'); + expect(mmkvStorage.saveEnsAvatar).toHaveBeenCalledWith( + address, + 'ensAvatarUrl', + ); + expect(result.current).toEqual({ + displayName: 'ensName', + avatarUrl: 'ensAvatarUrl', + loading: false, + }); + }); + + test('should handle error when fetching ENS info', async () => { + (mmkvStorage.getEnsName as jest.Mock).mockReturnValue(null); + (mmkvStorage.getEnsAvatar as jest.Mock).mockReturnValue(null); + (getEnsInfo as jest.Mock).mockRejectedValue( + new Error('Failed to fetch ENS info'), + ); + + const {result, waitForNextUpdate} = renderHook(() => + useContactInfo(address), + ); + + await act(async () => { + await waitForNextUpdate(); + }); + + expect(getEnsInfo).toHaveBeenCalledWith(address); + expect(result.current).toEqual({ + displayName: `Formatted: ${address}`, + avatarUrl: null, + loading: false, + }); + }); + + test('should save displayName to mmkvStorage when displayName changes', async () => { + (mmkvStorage.getEnsName as jest.Mock).mockReturnValue(null); + (mmkvStorage.getEnsAvatar as jest.Mock).mockReturnValue(null); + (getEnsInfo as jest.Mock).mockResolvedValue({ + ens: 'ensName', + avatarUrl: 'ensAvatarUrl', + }); + + const {waitForNextUpdate} = renderHook(() => useContactInfo(address)); + + await act(async () => { + await waitForNextUpdate(); + }); + + expect(mmkvStorage.saveEnsName).toHaveBeenCalledWith(address, 'ensName'); + }); +}); diff --git a/src/hooks/useDebounce.test.tsx b/src/hooks/useDebounce.test.tsx new file mode 100644 index 0000000..0d1beaa --- /dev/null +++ b/src/hooks/useDebounce.test.tsx @@ -0,0 +1,98 @@ +import {act, renderHook} from '@testing-library/react-hooks'; +import {useDebounce} from './useDebounce'; + +jest.useFakeTimers(); + +describe('useDebounce', () => { + test('should return the initial value immediately', () => { + const {result} = renderHook(() => useDebounce('initial', 500)); + expect(result.current).toBe('initial'); + }); + + test('should debounce the value after the delay', () => { + const {result, rerender} = renderHook( + ({value, delay}) => useDebounce(value, delay), + { + initialProps: {value: 'initial', delay: 500}, + }, + ); + + rerender({value: 'new value', delay: 500}); + + // Initially, the value should not be updated + expect(result.current).toBe('initial'); + + // Fast forward the timers by 500ms + act(() => { + jest.advanceTimersByTime(500); + }); + + // Now, the debounced value should be updated + expect(result.current).toBe('new value'); + }); + + test('should update debounced value only after the specified delay', () => { + const {result, rerender} = renderHook( + ({value, delay}) => useDebounce(value, delay), + { + initialProps: {value: 'initial', delay: 500}, + }, + ); + + rerender({value: 'new value', delay: 500}); + + // Fast forward the timers by less than the delay time + act(() => { + jest.advanceTimersByTime(300); + }); + + // Value should not have updated yet + expect(result.current).toBe('initial'); + + // Fast forward the remaining time + act(() => { + jest.advanceTimersByTime(200); + }); + + // Now, the debounced value should be updated + expect(result.current).toBe('new value'); + }); + + test('should reset debounce timer if value changes within delay period', () => { + const {result, rerender} = renderHook( + ({value, delay}) => useDebounce(value, delay), + { + initialProps: {value: 'initial', delay: 500}, + }, + ); + + rerender({value: 'new value', delay: 500}); + + // Fast forward the timers by 300ms + act(() => { + jest.advanceTimersByTime(300); + }); + + // Value should not have updated yet + expect(result.current).toBe('initial'); + + // Change the value again before the delay period completes + rerender({value: 'another value', delay: 500}); + + // Fast forward the timers by 300ms + act(() => { + jest.advanceTimersByTime(300); + }); + + // Value should still not have updated because the timer was reset + expect(result.current).toBe('initial'); + + // Fast forward the remaining time + act(() => { + jest.advanceTimersByTime(200); + }); + + // Now, the debounced value should be updated to the latest value + expect(result.current).toBe('another value'); + }); +}); diff --git a/src/hooks/useEnsAddress.test.tsx b/src/hooks/useEnsAddress.test.tsx new file mode 100644 index 0000000..ed50cbf --- /dev/null +++ b/src/hooks/useEnsAddress.test.tsx @@ -0,0 +1,62 @@ +import {act, renderHook} from '@testing-library/react-hooks'; +import {getEnsAddress} from 'viem/ens'; +import {viemClient} from '../utils/viemClient'; +import {useEnsAddress} from './useEnsAddress'; + +// Mock dependencies +jest.mock('viem/ens', () => ({ + getEnsAddress: jest.fn(), +})); + +jest.mock('../utils/viemClient', () => ({})); + +jest.mock('./useDebounce', () => ({ + useDebounce: jest.fn(value => value), +})); + +describe('useEnsAddress', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should return null initially', () => { + const {result} = renderHook(() => useEnsAddress()); + expect(result.current.data).toBeNull(); + }); + + test('should fetch ENS address and update state', async () => { + const mockAddress = '0x1234567890abcdef1234567890abcdef12345678'; + (getEnsAddress as jest.Mock).mockResolvedValueOnce(mockAddress); + + const {result, waitForNextUpdate} = renderHook(() => + useEnsAddress('vitalik.eth'), + ); + + await act(async () => { + await waitForNextUpdate({timeout: 2000}); + }); + + expect(getEnsAddress).toHaveBeenCalledWith(viemClient, { + name: 'vitalik.eth', + }); + expect(result.current.data).toBe(mockAddress); + }); + + test('should set ENS address to null if search text is empty', async () => { + (getEnsAddress as jest.Mock).mockResolvedValueOnce( + '0x1234567890abcdef1234567890abcdef12345678', + ); + + const {result, rerender} = renderHook( + ({searchText}) => useEnsAddress(searchText), + { + initialProps: {searchText: 'vitalik.eth'}, + }, + ); + (getEnsAddress as jest.Mock).mockResolvedValueOnce(null); + + rerender({searchText: ''}); + + expect(result.current.data).toBeNull(); + }); +}); diff --git a/src/hooks/useEnsAddress.ts b/src/hooks/useEnsAddress.ts index 57a3458..f33297b 100644 --- a/src/hooks/useEnsAddress.ts +++ b/src/hooks/useEnsAddress.ts @@ -17,6 +17,7 @@ export const useEnsAddress = (searchText?: string) => { }) .catch(e => { console.error('Error getting ENS address', e); + setEnsAddress(null); }); } else { setEnsAddress(null); diff --git a/src/hooks/useGroupMessages.test.ts b/src/hooks/useGroupMessages.test.ts new file mode 100644 index 0000000..bd36637 --- /dev/null +++ b/src/hooks/useGroupMessages.test.ts @@ -0,0 +1,101 @@ +import {useQueryClient} from '@tanstack/react-query'; +import {act, renderHook} from '@testing-library/react-hooks'; +import {ContentTypes} from '../consts/ContentTypes'; +import {QueryKeys} from '../queries/QueryKeys'; +import {useGroupMessagesQuery} from '../queries/useGroupMessagesQuery'; +import {useGroup} from './useGroup'; +import {useGroupMessages} from './useGroupMessages'; + +// Mock dependencies +jest.mock('@tanstack/react-query', () => ({ + useQueryClient: jest.fn(), +})); + +jest.mock('./useGroup', () => ({ + useGroup: jest.fn(), +})); + +jest.mock('../queries/useGroupMessagesQuery', () => ({ + useGroupMessagesQuery: jest.fn(), +})); + +describe('useGroupMessages', () => { + const mockQueryClient = { + setQueryData: jest.fn(), + }; + + const mockGroup = { + streamGroupMessages: jest.fn(), + sync: jest.fn(), + memberAddresses: jest.fn(), + }; + + const topic = 'test-topic'; + + beforeEach(() => { + jest.clearAllMocks(); + (useQueryClient as jest.Mock).mockReturnValue(mockQueryClient); + (useGroup as jest.Mock).mockReturnValue({data: mockGroup}); + (useGroupMessagesQuery as jest.Mock).mockReturnValue({data: []}); + }); + + test('should set up the group message stream and handle incoming messages', async () => { + const mockMessage = { + contentTypeId: ContentTypes.Text, + content: 'Test message', + }; + + mockGroup.streamGroupMessages.mockImplementation(callback => { + callback(mockMessage); + return Promise.resolve(() => {}); + }); + + renderHook(() => useGroupMessages(topic)); + + expect(mockGroup.streamGroupMessages).toHaveBeenCalled(); + expect(mockQueryClient.setQueryData).toHaveBeenCalledWith( + [QueryKeys.GroupMessages, topic], + expect.any(Function), + ); + + const updateFn = mockQueryClient.setQueryData.mock.calls[0][1]; + const prevMessages: any[] = []; + const newMessages = updateFn(prevMessages); + expect(newMessages).toEqual([mockMessage, ...prevMessages]); + + if (mockMessage.contentTypeId === ContentTypes.GroupMembershipChange) { + await act(async () => { + await mockGroup.sync(); + const addresses = await mockGroup.memberAddresses(); + expect(mockQueryClient.setQueryData).toHaveBeenCalledWith( + [QueryKeys.GroupParticipants, topic], + addresses, + ); + }); + } + }); + + test('should clean up the group message stream on unmount', async () => { + const cancelCallback = jest.fn(); + mockGroup.streamGroupMessages.mockReturnValue( + Promise.resolve(cancelCallback), + ); + + const {unmount} = renderHook(() => useGroupMessages(topic)); + + await act(async () => { + unmount(); + }); + + expect(cancelCallback).toHaveBeenCalled(); + }); + + test('should return the result of useGroupMessagesQuery', () => { + const mockQueryData = {data: ['message1', 'message2']}; + (useGroupMessagesQuery as jest.Mock).mockReturnValue(mockQueryData); + + const {result} = renderHook(() => useGroupMessages(topic)); + + expect(result.current).toBe(mockQueryData); + }); +}); diff --git a/yarn.lock b/yarn.lock index e499b53..b1b0277 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1799,7 +1799,7 @@ dependencies: regenerator-runtime "^0.13.11" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.20.0", "@babel/runtime@^7.6.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.20.0", "@babel/runtime@^7.6.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": version "7.24.6" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.6.tgz#5b76eb89ad45e2e4a0a8db54c456251469a3358e" integrity sha512-Ja18XcETdEl5mzzACGd+DKgaGJzPTCow7EglgwTmHdwokzDFYh/MHua6lU6DV/hjF2IaOJ4oX2nqnjG7RElKOw== @@ -4582,6 +4582,25 @@ dependencies: "@tanstack/query-core" "5.40.0" +"@testing-library/jest-native@^5.4.3": + version "5.4.3" + resolved "https://registry.yarnpkg.com/@testing-library/jest-native/-/jest-native-5.4.3.tgz#9334c68eaf45db9eb20d0876728cc5d7fc2c3ea2" + integrity sha512-/sSDGaOuE+PJ1Z9Kp4u7PQScSVVXGud59I/qsBFFJvIbcn4P6yYw6cBnBmbPF+X9aRIsTJRDl6gzw5ZkJNm66w== + dependencies: + chalk "^4.1.2" + jest-diff "^29.0.1" + jest-matcher-utils "^29.0.1" + pretty-format "^29.0.3" + redent "^3.0.0" + +"@testing-library/react-hooks@^8.0.1": + version "8.0.1" + resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz#0924bbd5b55e0c0c0502d1754657ada66947ca12" + integrity sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g== + dependencies: + "@babel/runtime" "^7.12.5" + react-error-boundary "^3.1.0" + "@trysound/sax@0.2.0": version "0.2.0" resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" @@ -8249,7 +8268,7 @@ jest-config@^29.7.0: slash "^3.0.0" strip-json-comments "^3.1.1" -jest-diff@^29.7.0: +jest-diff@^29.0.1, jest-diff@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a" integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw== @@ -8335,7 +8354,7 @@ jest-leak-detector@^29.7.0: jest-get-type "^29.6.3" pretty-format "^29.7.0" -jest-matcher-utils@^29.7.0: +jest-matcher-utils@^29.0.1, jest-matcher-utils@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz#ae8fec79ff249fd592ce80e3ee474e83a6c44f12" integrity sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g== @@ -9345,6 +9364,11 @@ mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== +min-indent@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" + integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== + "minimatch@2 || 3", minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -10131,7 +10155,7 @@ pretty-format@^26.5.2, pretty-format@^26.6.2: ansi-styles "^4.0.0" react-is "^17.0.1" -pretty-format@^29.0.0, pretty-format@^29.7.0: +pretty-format@^29.0.0, pretty-format@^29.0.3, pretty-format@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== @@ -10311,6 +10335,13 @@ react-devtools-core@^4.27.7: shell-quote "^1.6.1" ws "^7" +react-error-boundary@^3.1.0: + version "3.1.4" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0" + integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA== + dependencies: + "@babel/runtime" "^7.12.5" + react-freeze@^1.0.0: version "1.0.4" resolved "https://registry.yarnpkg.com/react-freeze/-/react-freeze-1.0.4.tgz#cbbea2762b0368b05cbe407ddc9d518c57c6f3ad" @@ -10612,6 +10643,14 @@ recast@^0.21.0: source-map "~0.6.1" tslib "^2.0.1" +redent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" + integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg== + dependencies: + indent-string "^4.0.0" + strip-indent "^3.0.0" + reflect.getprototypeof@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz#3ab04c32a8390b770712b7a8633972702d278859" @@ -11368,6 +11407,13 @@ strip-final-newline@^2.0.0: resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== +strip-indent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001" + integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ== + dependencies: + min-indent "^1.0.0" + strip-json-comments@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"