From 103023e7d5f827358216f61c00d3ce982fe461b5 Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Fri, 27 Sep 2024 10:45:56 +0200 Subject: [PATCH 01/29] Use `useMutation` from `@tanstack/react-query` instead of from `wagmi/query` --- src/hooks/useConfirmTransaction.test.ts | 12 ++++++++++-- src/hooks/useConfirmTransaction.ts | 15 +++++++++------ src/hooks/useSendTransaction.test.ts | 12 ++++++++++-- src/hooks/useSendTransaction.ts | 16 +++++++++------- 4 files changed, 38 insertions(+), 17 deletions(-) diff --git a/src/hooks/useConfirmTransaction.test.ts b/src/hooks/useConfirmTransaction.test.ts index 6f3fe69..d1670e0 100644 --- a/src/hooks/useConfirmTransaction.test.ts +++ b/src/hooks/useConfirmTransaction.test.ts @@ -1,5 +1,5 @@ import { waitForTransactionReceipt } from 'wagmi/actions' -import * as wagmiQuery from 'wagmi/query' +import * as tanstackReactQuery from '@tanstack/react-query' import { waitFor } from '@testing-library/react' import { SafeClient } from '@safe-global/sdk-starter-kit' import { useConfirmTransaction } from '@/hooks/useConfirmTransaction.js' @@ -11,6 +11,14 @@ import { renderHookInQueryClientProvider } from '@test/utils.js' import { MutationKey, QueryKey } from '@/constants.js' import { queryClient } from '@/queryClient.js' +// This is necessary to set a spy on the `useMutation` function without getting the following error: +// "TypeError: Cannot redefine property: useMutation" +jest.mock('@tanstack/react-query', () => ({ + __esModule: true, + // @ts-ignore + ...jest.requireActual('@tanstack/react-query') +})) + describe('useConfirmTransaction', () => { const confirmResponseMock = { safeAddress: safeAddress, @@ -27,7 +35,7 @@ describe('useConfirmTransaction', () => { const useWaitForTransactionSpy = jest.spyOn(useWaitForTransaction, 'useWaitForTransaction') const useSignerClientSpy = jest.spyOn(useSignerClient, 'useSignerClient') - const useMutationSpy = jest.spyOn(wagmiQuery, 'useMutation') + const useMutationSpy = jest.spyOn(tanstackReactQuery, 'useMutation') const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries') const confirmMock = jest.fn().mockResolvedValue(confirmResponseMock) diff --git a/src/hooks/useConfirmTransaction.ts b/src/hooks/useConfirmTransaction.ts index bfa632e..c180fc6 100644 --- a/src/hooks/useConfirmTransaction.ts +++ b/src/hooks/useConfirmTransaction.ts @@ -1,5 +1,9 @@ -import { useMutation, UseMutationReturnType } from 'wagmi/query' -import { UseMutateAsyncFunction, UseMutateFunction } from '@tanstack/react-query' +import { + UseMutateAsyncFunction, + UseMutateFunction, + useMutation, + UseMutationResult +} from '@tanstack/react-query' import { SafeClientResult } from '@safe-global/sdk-starter-kit' import { ConfigParam, SafeConfigWithSigner } from '@/types/index.js' import { useSignerClient } from '@/hooks/useSignerClient.js' @@ -10,10 +14,9 @@ import { invalidateQueries } from '@/queryClient.js' type ConfirmTransactionVariables = { safeTxHash: string } export type UseConfirmTransactionParams = ConfigParam -export type UseConfirmTransactionReturnType = UseMutationReturnType< - SafeClientResult, - Error, - ConfirmTransactionVariables +export type UseConfirmTransactionReturnType = Omit< + UseMutationResult, + 'mutate' | 'mutateAsync' > & { confirmTransaction: UseMutateFunction< SafeClientResult, diff --git a/src/hooks/useSendTransaction.test.ts b/src/hooks/useSendTransaction.test.ts index da31ab2..5f3e195 100644 --- a/src/hooks/useSendTransaction.test.ts +++ b/src/hooks/useSendTransaction.test.ts @@ -1,5 +1,5 @@ import { waitForTransactionReceipt } from 'wagmi/actions' -import * as wagmiQuery from 'wagmi/query' +import * as tanstackReactQuery from '@tanstack/react-query' import { waitFor } from '@testing-library/react' import { SafeClient } from '@safe-global/sdk-starter-kit' import { useSendTransaction } from '@/hooks/useSendTransaction.js' @@ -11,6 +11,14 @@ import { renderHookInQueryClientProvider } from '@test/utils.js' import { MutationKey, QueryKey } from '@/constants.js' import { queryClient } from '@/queryClient.js' +// This is necessary to set a spy on the `useMutation` function without getting the following error: +// "TypeError: Cannot redefine property: useMutation" +jest.mock('@tanstack/react-query', () => ({ + __esModule: true, + // @ts-ignore + ...jest.requireActual('@tanstack/react-query') +})) + describe('useSendTransaction', () => { const transactionMock = { to: '0xABC', value: '0', data: '0x987' } @@ -29,7 +37,7 @@ describe('useSendTransaction', () => { const useWaitForTransactionSpy = jest.spyOn(useWaitForTransaction, 'useWaitForTransaction') const useSignerClientSpy = jest.spyOn(useSignerClient, 'useSignerClient') - const useMutationSpy = jest.spyOn(wagmiQuery, 'useMutation') + const useMutationSpy = jest.spyOn(tanstackReactQuery, 'useMutation') const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries') const sendMock = jest.fn().mockResolvedValue(sendResponseMock) diff --git a/src/hooks/useSendTransaction.ts b/src/hooks/useSendTransaction.ts index d30f825..867ab20 100644 --- a/src/hooks/useSendTransaction.ts +++ b/src/hooks/useSendTransaction.ts @@ -1,6 +1,9 @@ -import { useMutation, UseMutationReturnType } from 'wagmi/query' -import { UseMutateAsyncFunction, UseMutateFunction } from '@tanstack/react-query' -import { TransactionBase } from '@safe-global/safe-core-sdk-types' +import { + UseMutateAsyncFunction, + UseMutateFunction, + useMutation, + UseMutationResult +} from '@tanstack/react-query' import { SafeClientResult } from '@safe-global/sdk-starter-kit' import { ConfigParam, SafeConfigWithSigner } from '@/types/index.js' import { useSignerClient } from '@/hooks/useSignerClient.js' @@ -11,10 +14,9 @@ import { invalidateQueries } from '@/queryClient.js' type SendTransactionVariables = { transactions: TransactionBase[] } export type UseSendTransactionParams = ConfigParam -export type UseSendTransactionReturnType = UseMutationReturnType< - SafeClientResult, - Error, - SendTransactionVariables +export type UseSendTransactionReturnType = Omit< + UseMutationResult, + 'mutate' | 'mutateAsync' > & { sendTransaction: UseMutateFunction sendTransactionAsync: UseMutateAsyncFunction< From efdf31bc2ea1017365d59f9f213678c2b352f719 Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Fri, 27 Sep 2024 10:46:41 +0200 Subject: [PATCH 02/29] Remove debug log line --- src/hooks/usePublicClientQuery.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/hooks/usePublicClientQuery.ts b/src/hooks/usePublicClientQuery.ts index d16e4e8..9864025 100644 --- a/src/hooks/usePublicClientQuery.ts +++ b/src/hooks/usePublicClientQuery.ts @@ -27,14 +27,12 @@ export function usePublicClientQuery( const [config] = useConfig({ config: params.config }) const safeClient = usePublicClient({ config: params.config }) - const queryFn = useCallback(async () => { + const queryFn = useCallback(() => { if (!safeClient) { throw new Error('SafeClient not initialized') } - const result = await querySafeClientFn(safeClient) - console.log('Fetched data:', result) - return result + return querySafeClientFn(safeClient) }, [safeClient, querySafeClientFn]) return useQuery({ queryKey: [...queryKey, config], queryFn }) From c6f34101374c62988e599f98599e1699a5a4ddc2 Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Fri, 27 Sep 2024 10:50:19 +0200 Subject: [PATCH 03/29] refactor: Update `useSendTransaction` to handle SafeTransaction objects The useSendTransaction hook now handles both TransactionBase and SafeTransaction objects. It maps the transactions array and converts any SafeTransaction objects to the required format before sending them to the signerClient. --- src/hooks/useSendTransaction.ts | 11 ++++++++--- src/types/guards.ts | 7 +++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/hooks/useSendTransaction.ts b/src/hooks/useSendTransaction.ts index 867ab20..139f8c3 100644 --- a/src/hooks/useSendTransaction.ts +++ b/src/hooks/useSendTransaction.ts @@ -4,14 +4,15 @@ import { useMutation, UseMutationResult } from '@tanstack/react-query' +import { SafeTransaction, TransactionBase } from '@safe-global/safe-core-sdk-types' import { SafeClientResult } from '@safe-global/sdk-starter-kit' -import { ConfigParam, SafeConfigWithSigner } from '@/types/index.js' +import { ConfigParam, isSafeTransaction, SafeConfigWithSigner } from '@/types/index.js' import { useSignerClient } from '@/hooks/useSignerClient.js' import { useWaitForTransaction } from '@/hooks/useWaitForTransaction.js' import { MutationKey, QueryKey } from '@/constants.js' import { invalidateQueries } from '@/queryClient.js' -type SendTransactionVariables = { transactions: TransactionBase[] } +type SendTransactionVariables = { transactions: (TransactionBase | SafeTransaction)[] } export type UseSendTransactionParams = ConfigParam export type UseSendTransactionReturnType = Omit< @@ -50,7 +51,11 @@ export function useSendTransaction( throw new Error('No transactions provided') } - const result = await signerClient.send({ transactions }) + const result = await signerClient.send({ + transactions: transactions.map((tx) => + isSafeTransaction(tx) ? { to: tx.data.to, value: tx.data.value, data: tx.data.data } : tx + ) + }) if (result.transactions?.ethereumTxHash) { await waitForTransactionReceipt(result.transactions.ethereumTxHash) diff --git a/src/types/guards.ts b/src/types/guards.ts index 0aef711..0c81f2b 100644 --- a/src/types/guards.ts +++ b/src/types/guards.ts @@ -5,11 +5,18 @@ import { SafeModuleTransaction, SafeMultisigTransaction } from '@/types/index.js' +import { SafeTransaction } from '@safe-global/safe-core-sdk-types' export function isString(x: any): x is string { return typeof x === 'string' } +export const isSafeTransaction = (tx: any): tx is SafeTransaction => + tx.data !== undefined && + tx.data.to !== undefined && + tx.data.value !== undefined && + tx.data.data !== undefined + export function isSafeConfigWithSigner(config: SafeConfig): config is SafeConfigWithSigner { return config.signer != null } From aaf802822cea71b654721fda19eab6dc64644ef6 Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Fri, 27 Sep 2024 11:52:15 +0200 Subject: [PATCH 04/29] Add `isOwnerConnected` flag It defines whether the connected signer is an owner of the configured Safe --- src/hooks/useAuthenticate.test.ts | 68 ++++++++++++++++++++++++++++--- src/hooks/useAuthenticate.ts | 16 ++++++-- src/hooks/useSafe.test.ts | 8 ++-- src/hooks/useSafe.ts | 3 +- 4 files changed, 82 insertions(+), 13 deletions(-) diff --git a/src/hooks/useAuthenticate.test.ts b/src/hooks/useAuthenticate.test.ts index 29451fa..cbe8f39 100644 --- a/src/hooks/useAuthenticate.test.ts +++ b/src/hooks/useAuthenticate.test.ts @@ -1,16 +1,24 @@ import { act } from 'react' import { waitFor } from '@testing-library/react' import { SafeClient } from '@safe-global/sdk-starter-kit' -import { useAuthenticate } from '@/hooks/useAuthenticate.js' +import { useAuthenticate, UseConnectSignerReturnType } from '@/hooks/useAuthenticate.js' +import * as useOwners from '@/hooks/useSafeInfo/useOwners.js' +import * as useSignerAddress from '@/hooks/useSignerAddress.js' import { renderHookInMockedSafeProvider } from '@test/utils.js' -import { signerPrivateKeys } from '@test/fixtures/index.js' +import { safeInfo, signerPrivateKeys } from '@test/fixtures/index.js' +import { createQuerySuccessResult } from '@test/fixtures/queryResult.js' import { SafeContextType } from '@/SafeContext.js' describe('useAuthenticate', () => { const signerClientMock = { safeClient: 'signer' } as unknown as SafeClient const setSignerMock = jest.fn(() => Promise.resolve()) + const useOwnersSpy = jest.spyOn(useOwners, 'useOwners') + const useSignerAddressSpy = jest.spyOn(useSignerAddress, 'useSignerAddress') - const renderUseAuthenticate = async (context: Partial = {}) => { + const renderUseAuthenticate = async ( + context: Partial = {}, + expected: Partial = {} + ) => { const renderOptions = { signerClient: signerClientMock, setSigner: setSignerMock, @@ -23,55 +31,103 @@ describe('useAuthenticate', () => { expect(renderResult.result.current).toEqual({ connect: expect.any(Function), disconnect: expect.any(Function), - isSignerConnected: !!renderOptions.signerClient + isSignerConnected: false, + isOwnerConnected: false, + ...expected }) ) return renderResult } + const ownersQueryResultMock = createQuerySuccessResult(safeInfo.owners) + + beforeEach(() => { + useOwnersSpy.mockReturnValue(ownersQueryResultMock) + useSignerAddressSpy.mockReturnValue(safeInfo.owners[1]) + }) + afterEach(() => { jest.clearAllMocks() }) + it('if connected signer is not owner of the Safe `isOwnerConnected` should be false', async () => { + useSignerAddressSpy.mockReturnValueOnce(safeInfo.owners[0]) + useOwnersSpy.mockReturnValueOnce(createQuerySuccessResult(safeInfo.owners.slice(1))) + + const { + result: { + current: { isSignerConnected, isOwnerConnected } + } + } = await renderUseAuthenticate(undefined, { + isSignerConnected: true, + isOwnerConnected: false + }) + + expect(isSignerConnected).toBe(true) + expect(isOwnerConnected).toBe(false) + }) + describe('connect', () => { it('should create a new signer client if being called with a valid private key', async () => { - const { result } = await renderUseAuthenticate() + const { result } = await renderUseAuthenticate(undefined, { + isSignerConnected: true, + isOwnerConnected: true + }) await act(() => result.current.connect(signerPrivateKeys[1])) + expect(useOwnersSpy).toHaveBeenCalledTimes(1) + expect(useSignerAddressSpy).toHaveBeenCalledTimes(1) + expect(setSignerMock).toHaveBeenCalledTimes(1) expect(setSignerMock).toHaveBeenCalledWith(signerPrivateKeys[1]) }) it('should throw if being called with an empty private key string', async () => { + useSignerAddressSpy.mockReturnValueOnce(undefined) + const { result } = await renderUseAuthenticate() expect(() => result.current.connect('')).rejects.toThrow( 'Failed to connect because signer is empty' ) + expect(useOwnersSpy).toHaveBeenCalledTimes(1) + expect(useSignerAddressSpy).toHaveBeenCalledTimes(1) + expect(setSignerMock).toHaveBeenCalledTimes(0) }) }) describe('disconnect', () => { it('should set signer to `undefined` if connected', async () => { - const { result } = await renderUseAuthenticate() + const { result } = await renderUseAuthenticate(undefined, { + isSignerConnected: true, + isOwnerConnected: true + }) await act(() => result.current.disconnect()) + expect(useOwnersSpy).toHaveBeenCalledTimes(1) + expect(useSignerAddressSpy).toHaveBeenCalledTimes(1) + expect(setSignerMock).toHaveBeenCalledTimes(1) expect(setSignerMock).toHaveBeenCalledWith(undefined) }) it('should throw if being called when signerClient is not defined', async () => { + useSignerAddressSpy.mockReturnValueOnce(undefined) + const { result } = await renderUseAuthenticate({ signerClient: undefined }) expect(() => result.current.disconnect()).rejects.toThrow( 'Failed to disconnect because no signer is connected' ) + expect(useOwnersSpy).toHaveBeenCalledTimes(1) + expect(useSignerAddressSpy).toHaveBeenCalledTimes(1) + expect(setSignerMock).toHaveBeenCalledTimes(0) }) }) diff --git a/src/hooks/useAuthenticate.ts b/src/hooks/useAuthenticate.ts index 4b8136c..5796f8c 100644 --- a/src/hooks/useAuthenticate.ts +++ b/src/hooks/useAuthenticate.ts @@ -1,11 +1,14 @@ -import { useCallback, useContext } from 'react' +import { useCallback, useContext, useMemo } from 'react' import { SafeContext } from '@/SafeContext.js' +import { useOwners } from '@/hooks/useSafeInfo/useOwners.js' +import { useSignerAddress } from '@/hooks/useSignerAddress.js' import { AuthenticationError } from '@/errors/AuthenticationError.js' export type UseConnectSignerReturnType = { connect: (signer: string) => Promise disconnect: () => Promise isSignerConnected: boolean + isOwnerConnected: boolean } /** @@ -14,6 +17,8 @@ export type UseConnectSignerReturnType = { */ export function useAuthenticate(): UseConnectSignerReturnType { const { signerClient, setSigner } = useContext(SafeContext) + const { data: owners } = useOwners() + const signerAddress = useSignerAddress() const connect = useCallback( async (signer: string) => { @@ -32,7 +37,12 @@ export function useAuthenticate(): UseConnectSignerReturnType { return setSigner(undefined) }, [setSigner]) - const isSignerConnected = !!signerClient + const isSignerConnected = !!signerAddress - return { connect, disconnect, isSignerConnected } + const isOwnerConnected = useMemo( + () => !!owners && !!signerAddress && owners.includes(signerAddress), + [owners, signerAddress] + ) + + return { connect, disconnect, isSignerConnected, isOwnerConnected } } diff --git a/src/hooks/useSafe.test.ts b/src/hooks/useSafe.test.ts index bff0a2f..3a94f9b 100644 --- a/src/hooks/useSafe.test.ts +++ b/src/hooks/useSafe.test.ts @@ -56,7 +56,7 @@ describe('useSafe', () => { jest.clearAllMocks() }) - it('should return object containing functions to call other hooks and `isInitialized` + `isSignerConnected` flags', async () => { + it('should return object containing functions to call other hooks and `isInitialized`, `isOwnerConnected` + `isSignerConnected` flags', async () => { const { result } = await renderUseSafeHook() expect(result.current).toMatchObject({ @@ -70,7 +70,8 @@ describe('useSafe', () => { getTransaction: expect.any(Function), getTransactions: expect.any(Function), isInitialized: true, - isSignerConnected: false + isSignerConnected: false, + isOwnerConnected: false }) }) @@ -87,7 +88,8 @@ describe('useSafe', () => { useAuthenticateSpy.mockReturnValue({ connect: connectMock, disconnect: disconnectMock, - isSignerConnected: false + isSignerConnected: false, + isOwnerConnected: false }) describe('connect', () => { diff --git a/src/hooks/useSafe.ts b/src/hooks/useSafe.ts index 20340ee..e3c2726 100644 --- a/src/hooks/useSafe.ts +++ b/src/hooks/useSafe.ts @@ -35,11 +35,12 @@ export function useSafe(): UseReturnType { throw new MissingSafeProviderError('`useSafe` must be used within `SafeProvider`.') } - const { connect, disconnect, isSignerConnected } = useAuthenticate() + const { connect, disconnect, isSignerConnected, isOwnerConnected } = useAuthenticate() return { isInitialized, isSignerConnected, + isOwnerConnected, connect, disconnect, getBalance: useBalance, From e990b78bb169d23a11b0c047084fd7709ea61f9b Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Fri, 27 Sep 2024 11:54:44 +0200 Subject: [PATCH 05/29] Add `useSignerClientMutation` hook for sending custom mutations via the SafeClient --- src/hooks/index.ts | 1 + src/hooks/useSignerClientMutation.ts | 47 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 src/hooks/useSignerClientMutation.ts diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 72e7c99..7be7e71 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -12,3 +12,4 @@ export * from './useSignerAddress.js' export * from './useSignerClient.js' export * from './useTransaction.js' export * from './useTransactions.js' +export * from './useWaitForTransaction.js' diff --git a/src/hooks/useSignerClientMutation.ts b/src/hooks/useSignerClientMutation.ts new file mode 100644 index 0000000..8a7a6d9 --- /dev/null +++ b/src/hooks/useSignerClientMutation.ts @@ -0,0 +1,47 @@ +import { useCallback } from 'react' +import { useMutation, type UseMutationResult } from '@tanstack/react-query' +import { SafeClient } from '@safe-global/sdk-starter-kit' +import { useConfig } from '@/hooks/useConfig.js' +import { useSignerClient } from '@/hooks//useSignerClient.js' +import type { ConfigParam, SafeConfigWithSigner } from '@/types/index.js' + +export type UseSignerClientMutationParams = + ConfigParam & { + mutationSafeClientFn: (safeClient: SafeClient, params: TParams) => Promise + mutationKey: string[] + } +export type UseSignerClientMutationReturnType = UseMutationResult< + TReturnData, + Error, + TParams +> + +/** + * Hook for sending a custom mutation via the SafeClient. + * @param params Parameters to customize the hook behavior. + * @param params.config SafeConfig to use instead of the one provided by `SafeProvider`. + * @param params.mutationSafeClientFn Mutation function to be called with the SafeClient. + * @param params.mutationKey Key to identify the mutation. + * @returns Object containing the mutation result. + */ +export function useSignerClientMutation( + params: UseSignerClientMutationParams +): UseSignerClientMutationReturnType { + const { mutationSafeClientFn, mutationKey } = params + + const [config] = useConfig({ config: params.config }) + const signerClient = useSignerClient({ config: params.config }) + + const mutationFn = useCallback( + (params: TParams) => { + if (!signerClient) { + throw new Error('Signer client is not available') + } + + return mutationSafeClientFn(signerClient, params) + }, + [signerClient, mutationSafeClientFn] + ) + + return useMutation({ mutationKey: [...mutationKey, config], mutationFn }) +} From e59668aa5d650ffc19264b8660bfc09635376874 Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Fri, 27 Sep 2024 11:56:29 +0200 Subject: [PATCH 06/29] Add UpdateThreshold hook for updating the threshold of the connected Safe --- src/constants.ts | 3 +- src/hooks/index.ts | 1 + src/hooks/useUpdateThreshold.ts | 61 +++++++++++++++++++++++++++++++++ src/index.ts | 7 +++- 4 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 src/hooks/useUpdateThreshold.ts diff --git a/src/constants.ts b/src/constants.ts index dc6d5c7..d945dd8 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -11,5 +11,6 @@ export enum QueryKey { export enum MutationKey { SendTransaction = 'sendTransaction', - ConfirmTransaction = 'confirmTransaction' + ConfirmTransaction = 'confirmTransaction', + UpdateThreshold = 'updateThreshold' } diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 7be7e71..d5f4ddd 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -12,4 +12,5 @@ export * from './useSignerAddress.js' export * from './useSignerClient.js' export * from './useTransaction.js' export * from './useTransactions.js' +export * from './useUpdateThreshold.js' export * from './useWaitForTransaction.js' diff --git a/src/hooks/useUpdateThreshold.ts b/src/hooks/useUpdateThreshold.ts new file mode 100644 index 0000000..340a679 --- /dev/null +++ b/src/hooks/useUpdateThreshold.ts @@ -0,0 +1,61 @@ +import { + UseMutateAsyncFunction, + UseMutateFunction, + useMutation, + UseMutationResult +} from '@tanstack/react-query' +import { SafeClientResult } from '@safe-global/sdk-starter-kit' +import { ConfigParam, SafeConfigWithSigner } from '@/types/index.js' +import { useSignerClient } from '@/hooks/useSignerClient.js' +import { MutationKey } from '@/constants.js' +import { useSendTransaction } from './useSendTransaction.js' + +type UpdateThresholdVariables = { threshold: number } + +export type UseUpdateThresholdParams = ConfigParam +export type UseUpdateThresholdReturnType = Omit< + UseMutationResult, + 'mutate' | 'mutateAsync' +> & { + updateThreshold: UseMutateFunction + updateThresholdAsync: UseMutateAsyncFunction< + SafeClientResult, + Error, + UpdateThresholdVariables, + unknown + > +} + +/** + * Hook to update the threshold of the connected Safe. + * @param params Parameters to customize the hook behavior. + * @param params.config SafeConfig to use instead of the one provided by `SafeProvider`. + * @returns Object containing the mutation state and the function to update the threshold. + */ +export function useUpdateThreshold( + params: UseUpdateThresholdParams = {} +): UseUpdateThresholdReturnType { + const signerClient = useSignerClient({ config: params.config }) + const { sendTransactionAsync } = useSendTransaction({ config: params.config }) + + const mutationFn = async ({ threshold }: UpdateThresholdVariables) => { + if (!signerClient) { + throw new Error('Signer client is not available') + } + + if (threshold === 0) { + throw new Error('Threshold needs to be greater than 0') + } + + const updateThresholdTx = await signerClient.protocolKit.createChangeThresholdTx(threshold) + + return sendTransactionAsync({ transactions: [updateThresholdTx] }) + } + + const { mutate, mutateAsync, ...result } = useMutation({ + mutationFn, + mutationKey: [MutationKey.UpdateThreshold] + }) + + return { ...result, updateThreshold: mutate, updateThresholdAsync: mutateAsync } +} diff --git a/src/index.ts b/src/index.ts index 5605a99..ab102f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,9 @@ -export { useConfirmTransaction, useSafe, useSendTransaction } from '@/hooks/index.js' +export { + useConfirmTransaction, + useSafe, + useSendTransaction, + useUpdateThreshold +} from '@/hooks/index.js' export * from '@/types/index.js' export { SafeProvider } from './SafeProvider.js' export { SafeContext } from './SafeContext.js' From 596e64ab3afdbca262681147e6bf94e3ca7ed87a Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Fri, 27 Sep 2024 11:58:04 +0200 Subject: [PATCH 07/29] Add 'useAddOwner' hook for adding an owner to the connected Safe --- src/constants.ts | 3 +- src/hooks/useUpdateOwners/useAddOwner.ts | 41 ++++++++++++++++++++++++ src/index.ts | 1 + 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useUpdateOwners/useAddOwner.ts diff --git a/src/constants.ts b/src/constants.ts index d945dd8..5f11037 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -12,5 +12,6 @@ export enum QueryKey { export enum MutationKey { SendTransaction = 'sendTransaction', ConfirmTransaction = 'confirmTransaction', - UpdateThreshold = 'updateThreshold' + UpdateThreshold = 'updateThreshold', + AddOwner = 'addOwner' } diff --git a/src/hooks/useUpdateOwners/useAddOwner.ts b/src/hooks/useUpdateOwners/useAddOwner.ts new file mode 100644 index 0000000..904609a --- /dev/null +++ b/src/hooks/useUpdateOwners/useAddOwner.ts @@ -0,0 +1,41 @@ +import { UseMutateAsyncFunction, UseMutateFunction, UseMutationResult } from '@tanstack/react-query' +import { SafeClient, SafeClientResult } from '@safe-global/sdk-starter-kit' +import { ConfigParam, SafeConfigWithSigner } from '@/types/index.js' +import { useSignerClientMutation } from '@/hooks/useSignerClientMutation.js' +import { useSendTransaction } from '@/hooks/useSendTransaction.js' +import { MutationKey } from '@/constants.js' + +type AddOwnerVariables = Parameters[0] + +export type UseAddOwnerParams = ConfigParam +export type UseAddOwnerReturnType = Omit< + UseMutationResult, + 'mutate' | 'mutateAsync' +> & { + addOwner: UseMutateFunction + addOwnerAsync: UseMutateAsyncFunction +} + +/** + * Hook to add an owner to the connected Safe. + * @param params Parameters to customize the hook behavior. + * @param params.config SafeConfig to use instead of the one provided by `SafeProvider`. + * @returns Object containing the mutation state and the function to add an owner. + */ +export function useAddOwner(params: UseAddOwnerParams = {}): UseAddOwnerReturnType { + const { sendTransactionAsync } = useSendTransaction({ config: params.config }) + + const { mutate, mutateAsync, ...result } = useSignerClientMutation< + SafeClientResult, + AddOwnerVariables + >({ + ...params, + mutationKey: [MutationKey.AddOwner], + mutationSafeClientFn: async (safeClient, params) => { + const addOwnerTx = await safeClient.protocolKit.createAddOwnerTx(params) + return sendTransactionAsync({ transactions: [addOwnerTx] }) + } + }) + + return { ...result, addOwner: mutate, addOwnerAsync: mutateAsync } +} diff --git a/src/index.ts b/src/index.ts index ab102f4..62050f1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ export { useConfirmTransaction, useSafe, useSendTransaction, + useUpdateOwners, useUpdateThreshold } from '@/hooks/index.js' export * from '@/types/index.js' From 2ca3247a84465956fd0473b1b50afb15385b0ab4 Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Fri, 27 Sep 2024 11:58:43 +0200 Subject: [PATCH 08/29] Add `useRemoveOwner` hook for removing an owner from the connected Safe --- src/constants.ts | 3 +- src/hooks/useUpdateOwners/useRemoveOwner.ts | 41 +++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useUpdateOwners/useRemoveOwner.ts diff --git a/src/constants.ts b/src/constants.ts index 5f11037..6175ce0 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -13,5 +13,6 @@ export enum MutationKey { SendTransaction = 'sendTransaction', ConfirmTransaction = 'confirmTransaction', UpdateThreshold = 'updateThreshold', - AddOwner = 'addOwner' + AddOwner = 'addOwner', + RemoveOwner = 'removeOwner' } diff --git a/src/hooks/useUpdateOwners/useRemoveOwner.ts b/src/hooks/useUpdateOwners/useRemoveOwner.ts new file mode 100644 index 0000000..794ea51 --- /dev/null +++ b/src/hooks/useUpdateOwners/useRemoveOwner.ts @@ -0,0 +1,41 @@ +import { UseMutateAsyncFunction, UseMutateFunction, UseMutationResult } from '@tanstack/react-query' +import { SafeClient, SafeClientResult } from '@safe-global/sdk-starter-kit' +import { ConfigParam, SafeConfigWithSigner } from '@/types/index.js' +import { useSendTransaction } from '@/hooks/useSendTransaction.js' +import { useSignerClientMutation } from '@/hooks/useSignerClientMutation.js' +import { MutationKey } from '@/constants.js' + +type RemoveOwnerVariables = Parameters[0] + +export type UseRemoveOwnerParams = ConfigParam +export type UseRemoveOwnerReturnType = Omit< + UseMutationResult, + 'mutate' | 'mutateAsync' +> & { + removeOwner: UseMutateFunction + removeOwnerAsync: UseMutateAsyncFunction +} + +/** + * Hook to remove an owner from the connected Safe. + * @param params Parameters to customize the hook behavior. + * @param params.config SafeConfig to use instead of the one provided by `SafeProvider`. + * @returns Object containing the mutation state and the function to remove an owner. + */ +export function useRemoveOwner(params: UseRemoveOwnerParams = {}): UseRemoveOwnerReturnType { + const { sendTransactionAsync } = useSendTransaction({ config: params.config }) + + const { mutate, mutateAsync, ...result } = useSignerClientMutation< + SafeClientResult, + RemoveOwnerVariables + >({ + ...params, + mutationKey: [MutationKey.RemoveOwner], + mutationSafeClientFn: async (safeClient, params) => { + const removeOwnerTx = await safeClient.protocolKit.createRemoveOwnerTx(params) + return sendTransactionAsync({ transactions: [removeOwnerTx] }) + } + }) + + return { ...result, removeOwner: mutate, removeOwnerAsync: mutateAsync } +} From a7550888cdc363a0e0e42263168c5f664256d507 Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Fri, 27 Sep 2024 11:59:11 +0200 Subject: [PATCH 09/29] Add `useSwapOwner` hook for swapping an owner of the connected Safe --- src/constants.ts | 1 + src/hooks/useUpdateOwners/useSwapOwner.ts | 41 +++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 src/hooks/useUpdateOwners/useSwapOwner.ts diff --git a/src/constants.ts b/src/constants.ts index 6175ce0..61eacfd 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -13,6 +13,7 @@ export enum MutationKey { SendTransaction = 'sendTransaction', ConfirmTransaction = 'confirmTransaction', UpdateThreshold = 'updateThreshold', + SwapOwner = 'swapOwner', AddOwner = 'addOwner', RemoveOwner = 'removeOwner' } diff --git a/src/hooks/useUpdateOwners/useSwapOwner.ts b/src/hooks/useUpdateOwners/useSwapOwner.ts new file mode 100644 index 0000000..5e67402 --- /dev/null +++ b/src/hooks/useUpdateOwners/useSwapOwner.ts @@ -0,0 +1,41 @@ +import { UseMutateAsyncFunction, UseMutateFunction, UseMutationResult } from '@tanstack/react-query' +import { SafeClient, SafeClientResult } from '@safe-global/sdk-starter-kit' +import { ConfigParam, SafeConfigWithSigner } from '@/types/index.js' +import { useSignerClientMutation } from '@/hooks/useSignerClientMutation.js' +import { useSendTransaction } from '@/hooks/useSendTransaction.js' +import { MutationKey } from '@/constants.js' + +type SwapOwnerVariables = Parameters[0] + +export type UseSwapOwnerParams = ConfigParam +export type UseSwapOwnerReturnType = Omit< + UseMutationResult, + 'mutate' | 'mutateAsync' +> & { + swapOwner: UseMutateFunction + swapOwnerAsync: UseMutateAsyncFunction +} + +/** + * Hook to swap an owner of the connected Safe with another address. + * @param params Parameters to customize the hook behavior. + * @param params.config SafeConfig to use instead of the one provided by `SafeProvider`. + * @returns Object containing the mutation state and the function to swap an owner. + */ +export function useSwapOwner(params: UseSwapOwnerParams = {}): UseSwapOwnerReturnType { + const { sendTransactionAsync } = useSendTransaction({ config: params.config }) + + const { mutate, mutateAsync, ...result } = useSignerClientMutation< + SafeClientResult, + SwapOwnerVariables + >({ + ...params, + mutationKey: [MutationKey.SwapOwner], + mutationSafeClientFn: async (safeClient, params) => { + const swapOwnerTx = await safeClient.protocolKit.createSwapOwnerTx(params) + return sendTransactionAsync({ transactions: [swapOwnerTx] }) + } + }) + + return { ...result, swapOwner: mutate, swapOwnerAsync: mutateAsync } +} From ae4af8598219ef168ade5a4f755f5eea8ef57f1c Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Fri, 27 Sep 2024 12:00:35 +0200 Subject: [PATCH 10/29] Add `useUpdateOwners` hook for managing owners of the connected Safe It wraps the individual hooks: - `useAddOwner` - `useRemoveOwner` - `useSwapOwner` --- src/hooks/index.ts | 1 + src/hooks/useUpdateOwners/index.ts | 24 +++++++++ yarn.lock | 83 ++++++++++++++++++++++++------ 3 files changed, 91 insertions(+), 17 deletions(-) create mode 100644 src/hooks/useUpdateOwners/index.ts diff --git a/src/hooks/index.ts b/src/hooks/index.ts index d5f4ddd..4bf66e3 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -7,6 +7,7 @@ export * from './usePendingTransactions.js' export * from './useSafe.js' export * from './usePublicClient.js' export * from './useSafeInfo/index.js' +export * from './useUpdateOwners/index.js' export * from './useSendTransaction.js' export * from './useSignerAddress.js' export * from './useSignerClient.js' diff --git a/src/hooks/useUpdateOwners/index.ts b/src/hooks/useUpdateOwners/index.ts new file mode 100644 index 0000000..404fb27 --- /dev/null +++ b/src/hooks/useUpdateOwners/index.ts @@ -0,0 +1,24 @@ +import { ConfigParam, SafeConfigWithSigner } from '@/types/index.js' +import { useAddOwner } from './useAddOwner.js' +import { useRemoveOwner } from './useRemoveOwner.js' +import { useSwapOwner } from './useSwapOwner.js' + +export type UseAddOwnerParams = ConfigParam + +export type UseUpdateOwnersReturnType = { + add: ReturnType + remove: ReturnType + swap: ReturnType +} + +/** + * Hook to add, remove or swap owners of the connected Safe. + * @returns Object wrapping the hooks to update, remove or swap owners. + */ +export function useUpdateOwners(params: UseAddOwnerParams = {}): UseUpdateOwnersReturnType { + const add = useAddOwner({ config: params.config }) + const remove = useRemoveOwner({ config: params.config }) + const swap = useSwapOwner({ config: params.config }) + + return { add, remove, swap } +} diff --git a/yarn.lock b/yarn.lock index 868cdc2..996ea30 100644 --- a/yarn.lock +++ b/yarn.lock @@ -910,7 +910,7 @@ resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.4.0.tgz#45814aa329f30e4fe0ba49426f49dfccdd066426" integrity sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg== -"@noble/hashes@^1.3.3": +"@noble/hashes@^1.3.3", "@noble/hashes@~1.5.0": version "1.5.0" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.5.0.tgz#abadc5ca20332db2b1b2aa3e496e9af1213570b0" integrity sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA== @@ -1033,17 +1033,17 @@ "@parcel/watcher-win32-ia32" "2.4.1" "@parcel/watcher-win32-x64" "2.4.1" -"@safe-global/api-kit@^2.4.3": - version "2.4.5" - resolved "https://registry.yarnpkg.com/@safe-global/api-kit/-/api-kit-2.4.5.tgz#e2fb5d9b0949c8f8726e4e9f3343031ff38802b5" - integrity sha512-kMV6snDLSuoXLpEHKDbbURBda8iaMMDZp19uDj4d34smIUIPLE6XHpjXR+1S6i+GkaJH/kdIrCe6v4/ip/BjpQ== +"@safe-global/api-kit@^2.4.5": + version "2.4.6" + resolved "https://registry.yarnpkg.com/@safe-global/api-kit/-/api-kit-2.4.6.tgz#b1377ee16d9af2db29f59bce5262ffad59f61b82" + integrity sha512-57lXrqXnmdUdQ12ssWSVDZhpIY2HcJzDvR4w6edT8xebEaduKx2UpwRJ8U2WVEBrx5K9PYuLAPsPHs+/r0yuGg== dependencies: - "@safe-global/protocol-kit" "^4.1.0" + "@safe-global/protocol-kit" "^4.1.1" "@safe-global/safe-core-sdk-types" "^5.1.0" ethers "^6.13.1" node-fetch "^2.7.0" -"@safe-global/protocol-kit@^4.0.3", "@safe-global/protocol-kit@^4.1.0": +"@safe-global/protocol-kit@^4.1.0": version "4.1.0" resolved "https://registry.yarnpkg.com/@safe-global/protocol-kit/-/protocol-kit-4.1.0.tgz#8ab41e179c559840f0cd6b6ae296438dabe1793f" integrity sha512-WAGXEn6UvKGlEYNqcWUasLZ4240sVWBg8T2SsfHoTs8Im0x2i48CNNZ5Mw9x+oKqhWs/Q9frNG6JcycN19LDRw== @@ -1057,13 +1057,27 @@ ethers "^6.13.1" semver "^7.6.2" -"@safe-global/relay-kit@^3.0.3": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@safe-global/relay-kit/-/relay-kit-3.1.0.tgz#0323a318708b7f04bd076829c99feadd796e95c2" - integrity sha512-1kXAZmuYj0lw9Ab7SmYnhqKC7hSunpemDAVvhtXEM4CfI1pUStn5pGDAyJv0ASzt/xyXqtgtw52G+USz87vMdQ== +"@safe-global/protocol-kit@^4.1.1": + version "4.1.1" + resolved "https://registry.yarnpkg.com/@safe-global/protocol-kit/-/protocol-kit-4.1.1.tgz#3c23616578c7e071cca5e5d945a2f995b6903b0d" + integrity sha512-11Jui1gIpCOV1sUn5HlT+hVn/+gJnKjik0V0aUPDKIL2zajwc+jReZK43CH5GyOHKOF7gCs2yprMHOrQuKkkqw== + dependencies: + "@noble/hashes" "^1.3.3" + "@safe-global/safe-core-sdk-types" "^5.1.0" + "@safe-global/safe-deployments" "^1.37.9" + "@safe-global/safe-modules-deployments" "^2.2.1" + abitype "^1.0.2" + ethereumjs-util "^7.1.5" + ethers "^6.13.1" + semver "^7.6.2" + +"@safe-global/relay-kit@^3.1.0": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@safe-global/relay-kit/-/relay-kit-3.1.1.tgz#1538a4860320ad6751bc13b45c3b559bba9c14c4" + integrity sha512-KXSsl/0C4V5LFmeDwO+qXzA8H3dHxHKf+sHZJui6JCdAAiH1broW4BqHlK0izFZQXPALo5o1trpbrOdvttisiQ== dependencies: "@gelatonetwork/relay-sdk" "^5.5.0" - "@safe-global/protocol-kit" "^4.1.0" + "@safe-global/protocol-kit" "^4.1.1" "@safe-global/safe-core-sdk-types" "^5.1.0" "@safe-global/safe-modules-deployments" "^2.2.1" ethers "^6.13.1" @@ -1105,6 +1119,13 @@ dependencies: semver "^7.6.2" +"@safe-global/safe-deployments@^1.37.9": + version "1.37.10" + resolved "https://registry.yarnpkg.com/@safe-global/safe-deployments/-/safe-deployments-1.37.10.tgz#2f61a25bd479332821ba2e91a575237d77406ec3" + integrity sha512-lcxX9CV+xdcLs4dF6Cx18zDww5JyqaX6RdcvU0o/34IgJ4Wjo3J/RNzJAoMhurCAfTGr+0vyJ9V13Qo50AR6JA== + dependencies: + semver "^7.6.2" + "@safe-global/safe-gateway-typescript-sdk@^3.5.3": version "3.22.1" resolved "https://registry.yarnpkg.com/@safe-global/safe-gateway-typescript-sdk/-/safe-gateway-typescript-sdk-3.22.1.tgz#4d5dac21c6e044b68b13b53468633ec771f30e3b" @@ -1118,17 +1139,22 @@ "@safe-global/sdk-starter-kit@file:./safe-core-sdk/packages/sdk-starter-kit": version "1.0.0" dependencies: - "@safe-global/api-kit" "^2.4.3" - "@safe-global/protocol-kit" "^4.0.3" - "@safe-global/relay-kit" "^3.0.3" - "@safe-global/safe-core-sdk-types" "^5.0.3" - ethers "^6.13.1" + "@safe-global/api-kit" "^2.4.5" + "@safe-global/protocol-kit" "^4.1.0" + "@safe-global/relay-kit" "^3.1.0" + "@safe-global/safe-core-sdk-types" "^5.1.0" + viem "^2.21.8" "@scure/base@^1.1.3", "@scure/base@~1.1.6": version "1.1.7" resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.7.tgz#fe973311a5c6267846aa131bc72e96c5d40d2b30" integrity sha512-PPNYBslrLNNUQ/Yad37MHYsNQtK67EhWb6WtSvNLLPo7SdVZgkUjD6Dg+5On7zNwmskf8OX7I7Nx5oN+MIWE0g== +"@scure/base@~1.1.8": + version "1.1.9" + resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.9.tgz#e5e142fbbfe251091f9c5f1dd4c834ac04c3dbd1" + integrity sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg== + "@scure/bip32@1.4.0": version "1.4.0" resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.4.0.tgz#4e1f1e196abedcef395b33b9674a042524e20d67" @@ -1146,6 +1172,14 @@ "@noble/hashes" "~1.4.0" "@scure/base" "~1.1.6" +"@scure/bip39@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.4.0.tgz#664d4f851564e2e1d4bffa0339f9546ea55960a6" + integrity sha512-BEEm6p8IueV/ZTfQLp/0vhw4NPnT9oWf5+28nvmeUICjP99f4vr2d+qc7AVGDDtwRep6ifR43Yed9ERVmiITzw== + dependencies: + "@noble/hashes" "~1.5.0" + "@scure/base" "~1.1.8" + "@sinclair/typebox@^0.27.8": version "0.27.8" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" @@ -5595,6 +5629,21 @@ viem@^2.1.1, viem@^2.18.6: webauthn-p256 "0.0.5" ws "8.17.1" +viem@^2.21.8: + version "2.21.15" + resolved "https://registry.yarnpkg.com/viem/-/viem-2.21.15.tgz#068c010946151e7f256bb7da601ab9b8287c583b" + integrity sha512-Ae05NQzMsqPWRwuAHf1OfmL0SjI+1GBgiFB0JA9BAbK/61nJXsTPsQxfV5CbLe4c3ct8IEZTX89rdeW4dqf97g== + dependencies: + "@adraffy/ens-normalize" "1.10.0" + "@noble/curves" "1.4.0" + "@noble/hashes" "1.4.0" + "@scure/bip32" "1.4.0" + "@scure/bip39" "1.4.0" + abitype "1.0.5" + isows "1.0.4" + webauthn-p256 "0.0.5" + ws "8.17.1" + w3c-xmlserializer@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz#aebdc84920d806222936e3cdce408e32488a3073" From 85f161dc4cd3e86d37a8871edb93c83f986490cd Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Fri, 27 Sep 2024 14:43:16 +0200 Subject: [PATCH 11/29] refactor: Update usePendingTransactions to use usePublicClientQuery Refactor the usePendingTransactions hook to use the usePublicClientQuery hook instead of the usePublicClient hook. --- src/hooks/usePendingTransactions.test.ts | 139 +++++++++++++++-------- src/hooks/usePendingTransactions.ts | 31 +++-- 2 files changed, 108 insertions(+), 62 deletions(-) diff --git a/src/hooks/usePendingTransactions.test.ts b/src/hooks/usePendingTransactions.test.ts index d32f1ac..0052a16 100644 --- a/src/hooks/usePendingTransactions.test.ts +++ b/src/hooks/usePendingTransactions.test.ts @@ -1,80 +1,129 @@ -import { waitFor } from '@testing-library/react' +import { UseQueryResult } from '@tanstack/react-query' import { SafeClient } from '@safe-global/sdk-starter-kit' -import { usePendingTransactions } from '@/hooks/usePendingTransactions.js' -import * as usePublicClient from '@/hooks/usePublicClient.js' -import * as useConfig from '@/hooks/useConfig.js' +import { + usePendingTransactions, + UsePendingTransactionsParams, + UsePendingTransactionsReturnType +} from '@/hooks/usePendingTransactions.js' +import * as usePublicClientQuery from '@/hooks/usePublicClientQuery.js' +import * as useIsDeployed from '@/hooks/useSafeInfo/useIsDeployed.js' import { safeMultisigTransaction } from '@test/fixtures/index.js' import { renderHookInQueryClientProvider } from '@test/utils.js' -import { configExistingSafe, configPredictedSafe } from '@test/config.js' +import { configPredictedSafe } from '@test/config.js' +import { SafeMultisigTransaction } from '@/types/index.js' +import { QueryKey } from '@/constants.js' describe('usePendingTransactions', () => { - const useConfigSpy = jest.spyOn(useConfig, 'useConfig') - const usePublicClientSpy = jest.spyOn(usePublicClient, 'usePublicClient') + const usePublicClientQuerySpy = jest.spyOn(usePublicClientQuery, 'usePublicClientQuery') + const useIsDeployedSpy = jest.spyOn(useIsDeployed, 'useIsDeployed') + + const pendingTransactionsMock = [safeMultisigTransaction] + const getPendingTransactionsMock = jest.fn().mockResolvedValue({ + results: pendingTransactionsMock, + count: 1 + }) + + const pendingTransactionsQueryResultMock = { + data: pendingTransactionsMock, + status: 'success' + } as unknown as UseQueryResult + const publicClientMock = { - getPendingTransactions: jest - .fn() - .mockResolvedValue({ results: [safeMultisigTransaction], count: 1 }) + getPendingTransactions: getPendingTransactionsMock + } as unknown as SafeClient + + const renderUsePendingTransactions = ( + params?: UsePendingTransactionsParams, + result: UsePendingTransactionsReturnType = pendingTransactionsQueryResultMock + ) => { + let querySafeClientFn: (safeClient: SafeClient) => unknown + + usePublicClientQuerySpy.mockImplementation(({ querySafeClientFn: _querySafeClientFn }) => { + querySafeClientFn = _querySafeClientFn + return result + }) + + const renderResult = renderHookInQueryClientProvider(() => usePendingTransactions(params)) + + return { renderResult, querySafeClientFn: querySafeClientFn! } } beforeEach(() => { - useConfigSpy.mockReturnValue([configExistingSafe, () => {}]) - usePublicClientSpy.mockReturnValue(publicClientMock as unknown as SafeClient) + useIsDeployedSpy.mockReturnValue({ data: true } as useIsDeployed.UseIsDeployedReturnType) }) afterEach(() => { jest.clearAllMocks() }) - it('should return fetch and return pending Safe transactions via SafeClient', async () => { - const { result } = renderHookInQueryClientProvider(() => usePendingTransactions()) + it('should return fetch and return pending Safe transactions via SafeClient', () => { + const { renderResult, querySafeClientFn } = renderUsePendingTransactions() - expect(result.current).toMatchObject({ data: undefined, status: 'pending' }) + expect(renderResult.result.current).toMatchObject(pendingTransactionsQueryResultMock) - await waitFor(() => - expect(result.current).toMatchObject({ - data: [safeMultisigTransaction], - status: 'success' - }) - ) + expect(usePublicClientQuerySpy).toHaveBeenCalledTimes(1) + expect(usePublicClientQuerySpy).toHaveBeenCalledWith({ + querySafeClientFn: expect.any(Function), + queryKey: [QueryKey.PendingTransactions] + }) - expect(publicClientMock.getPendingTransactions).toHaveBeenCalledTimes(1) + expect(useIsDeployedSpy).toHaveBeenCalledTimes(1) + expect(useIsDeployedSpy).toHaveBeenCalledWith({ config: undefined }) - expect(usePublicClientSpy).toHaveBeenCalledTimes(2) - expect(usePublicClientSpy).toHaveBeenCalledWith({ config: undefined }) + expect(getPendingTransactionsMock).toHaveBeenCalledTimes(0) - expect(publicClientMock.getPendingTransactions).toHaveBeenCalledTimes(1) + querySafeClientFn(publicClientMock) + expect(getPendingTransactionsMock).toHaveBeenCalledTimes(1) + expect(getPendingTransactionsMock).toHaveBeenCalledWith() }) - it('should accept a config to override the one from the SafeProvider', async () => { - const { result } = renderHookInQueryClientProvider(() => - usePendingTransactions({ config: configPredictedSafe }) - ) + it('should accept a config to override the one from the SafeProvider', () => { + const { renderResult, querySafeClientFn } = renderUsePendingTransactions({ + config: configPredictedSafe + }) - expect(usePublicClientSpy).toHaveBeenCalledTimes(1) - expect(usePublicClientSpy).toHaveBeenCalledWith({ config: configPredictedSafe }) + expect(renderResult.result.current).toMatchObject(pendingTransactionsQueryResultMock) - await waitFor(() => - expect(result.current).toMatchObject({ data: [safeMultisigTransaction], status: 'success' }) - ) + expect(usePublicClientQuerySpy).toHaveBeenCalledTimes(1) + expect(usePublicClientQuerySpy).toHaveBeenCalledWith({ + config: configPredictedSafe, + querySafeClientFn: expect.any(Function), + queryKey: [QueryKey.PendingTransactions] + }) + + expect(useIsDeployedSpy).toHaveBeenCalledTimes(1) + expect(useIsDeployedSpy).toHaveBeenCalledWith({ config: configPredictedSafe }) - expect(usePublicClientSpy).toHaveBeenCalledTimes(2) - expect(usePublicClientSpy).toHaveBeenCalledWith({ config: configPredictedSafe }) + expect(getPendingTransactionsMock).toHaveBeenCalledTimes(0) - expect(publicClientMock.getPendingTransactions).toHaveBeenCalledTimes(1) + querySafeClientFn(publicClientMock) + expect(getPendingTransactionsMock).toHaveBeenCalledTimes(1) + expect(getPendingTransactionsMock).toHaveBeenCalledWith() }) - it('should return no data if request fails', async () => { - publicClientMock.getPendingTransactions.mockRejectedValueOnce( - new Error('Get pending transactions error') + it('should throw if Safe is not deployed', async () => { + const errorResultMock = { data: undefined, status: 'error' } as UsePendingTransactionsReturnType + + useIsDeployedSpy.mockReturnValue({ data: false } as useIsDeployed.UseIsDeployedReturnType) + + const { renderResult, querySafeClientFn } = renderUsePendingTransactions( + undefined, + errorResultMock ) - const { result } = renderHookInQueryClientProvider(() => usePendingTransactions()) + expect(() => querySafeClientFn(publicClientMock)).rejects.toThrow('Safe is not deployed') + + expect(usePublicClientQuerySpy).toHaveBeenCalledTimes(1) + expect(usePublicClientQuerySpy).toHaveBeenCalledWith({ + querySafeClientFn: expect.any(Function), + queryKey: [QueryKey.PendingTransactions] + }) - expect(usePublicClientSpy).toHaveBeenCalledTimes(1) - expect(usePublicClientSpy).toHaveBeenCalledWith({ config: undefined }) + expect(useIsDeployedSpy).toHaveBeenCalledTimes(1) + expect(useIsDeployedSpy).toHaveBeenCalledWith({ config: undefined }) - expect(result.current).toMatchObject({ data: undefined, status: 'pending' }) + expect(renderResult.result.current).toMatchObject(errorResultMock) - expect(publicClientMock.getPendingTransactions).toHaveBeenCalledTimes(1) + expect(getPendingTransactionsMock).not.toHaveBeenCalled() }) }) diff --git a/src/hooks/usePendingTransactions.ts b/src/hooks/usePendingTransactions.ts index 33e5ee7..292d5fc 100644 --- a/src/hooks/usePendingTransactions.ts +++ b/src/hooks/usePendingTransactions.ts @@ -1,7 +1,6 @@ -import { useCallback } from 'react' -import { useQuery, type UseQueryResult } from '@tanstack/react-query' -import { useConfig } from '@/hooks/useConfig.js' -import { usePublicClient } from '@/hooks/usePublicClient.js' +import { type UseQueryResult } from '@tanstack/react-query' +import { useIsDeployed } from '@/hooks/useSafeInfo/useIsDeployed.js' +import { usePublicClientQuery } from '@/hooks/usePublicClientQuery.js' import type { ConfigParam, SafeConfig, SafeMultisigTransaction } from '@/types/index.js' import { QueryKey } from '@/constants.js' @@ -17,20 +16,18 @@ export type UsePendingTransactionsReturnType = UseQueryResult { - if (!safeClient) { - throw new Error('SafeClient not initialized') - } + return usePublicClientQuery({ + ...params, + querySafeClientFn: async (safeClient) => { + if (!isDeployed) { + throw new Error('Safe is not deployed') + } - const response = await safeClient.getPendingTransactions() - return response.results - }, [safeClient]) - - return useQuery({ - queryKey: [QueryKey.PendingTransactions, config], - queryFn: getPendingTransactions + const { results } = await safeClient.getPendingTransactions() + return results + }, + queryKey: [QueryKey.PendingTransactions] }) } From 3164ccdfc2f8d249d5b7c1dca38756953511f3c2 Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Fri, 27 Sep 2024 17:28:44 +0200 Subject: [PATCH 12/29] Unit tests for `useSignerClientMutation` hook --- src/hooks/useSignerClientMutation.test.ts | 255 ++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 src/hooks/useSignerClientMutation.test.ts diff --git a/src/hooks/useSignerClientMutation.test.ts b/src/hooks/useSignerClientMutation.test.ts new file mode 100644 index 0000000..cbae888 --- /dev/null +++ b/src/hooks/useSignerClientMutation.test.ts @@ -0,0 +1,255 @@ +import { waitFor } from '@testing-library/react' +import * as tanstackQuery from '@tanstack/react-query' +import { SafeClient } from '@safe-global/sdk-starter-kit' +import { useSignerClientMutation } from '@/hooks/useSignerClientMutation.js' +import * as useSignerClient from '@/hooks/useSignerClient.js' +import * as useConfig from '@/hooks/useConfig.js' +import { configExistingSafe } from '@test/config.js' +import { safeMultisigTransaction, signerPrivateKeys } from '@test/fixtures/index.js' +import { renderHookInQueryClientProvider } from '@test/utils.js' + +// This is necessary to set a spy on the `useMutation` function without getting the following error: +// "TypeError: Cannot redefine property: useMutation" +jest.mock('@tanstack/react-query', () => ({ + __esModule: true, + // @ts-ignore + ...jest.requireActual('@tanstack/react-query') +})) + +describe('useSignerClientMutation', () => { + const useConfigSpy = jest.spyOn(useConfig, 'useConfig') + const useSignerClientSpy = jest.spyOn(useSignerClient, 'useSignerClient') + const useMutationSpy = jest.spyOn(tanstackQuery, 'useMutation') + + const createAddOwnerTxMock = jest.fn().mockResolvedValue(safeMultisigTransaction) + + const signerClientMock = { protocolKit: { createAddOwnerTx: createAddOwnerTxMock } } + const mutationKeyMock = 'test-mutation-key' + + const mutationSafeClientFnMock = jest.fn((safeClient, params) => + safeClient.protocolKit.createAddOwnerTx(params) + ) + + beforeEach(() => { + useConfigSpy.mockReturnValue([configExistingSafe, () => {}]) + useSignerClientSpy.mockReturnValue(signerClientMock as unknown as SafeClient) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it.each([ + ['without config parameter', { config: undefined }], + ['with config parameter', { config: { ...configExistingSafe, signer: signerPrivateKeys[0] } }] + ])('should initialize signer client correctly when being called %s', async (_label, params) => { + const { result } = renderHookInQueryClientProvider(() => + useSignerClientMutation({ + ...params, + mutationSafeClientFn: mutationSafeClientFnMock, + mutationKey: [mutationKeyMock] + }) + ) + await waitFor(() => expect(result.current.isIdle).toEqual(true)) + + expect(useSignerClientSpy).toHaveBeenCalledTimes(1) + expect(useSignerClientSpy).toHaveBeenCalledWith(params) + + expect(useConfigSpy).toHaveBeenCalledTimes(1) + expect(useConfigSpy).toHaveBeenCalledWith(params) + + expect(mutationSafeClientFnMock).toHaveBeenCalledTimes(0) + expect(createAddOwnerTxMock).toHaveBeenCalledTimes(0) + }) + + it('should return mutation result object', async () => { + const { result } = renderHookInQueryClientProvider(() => + useSignerClientMutation({ + mutationSafeClientFn: mutationSafeClientFnMock, + mutationKey: [mutationKeyMock] + }) + ) + await waitFor(() => expect(result.current.isIdle).toEqual(true)) + + expect(result.current).toEqual({ + context: undefined, + data: undefined, + error: null, + failureCount: 0, + failureReason: null, + isPaused: false, + status: 'idle', + variables: undefined, + submittedAt: 0, + isPending: false, + isSuccess: false, + isError: false, + isIdle: true, + reset: expect.any(Function), + mutate: expect.any(Function), + mutateAsync: expect.any(Function) + }) + + expect(useMutationSpy).toHaveBeenCalledTimes(1) + expect(useMutationSpy).toHaveBeenCalledWith({ + mutationFn: expect.any(Function), + mutationKey: [mutationKeyMock, configExistingSafe] + }) + + expect(mutationSafeClientFnMock).toHaveBeenCalledTimes(0) + expect(createAddOwnerTxMock).toHaveBeenCalledTimes(0) + }) + + describe('when calling the `mutate` function', () => { + it('should call the passed `mutationSafeClientFn` function with a SafeClient instance and return the mutation result', async () => { + const { result } = renderHookInQueryClientProvider(() => + useSignerClientMutation({ + mutationSafeClientFn: mutationSafeClientFnMock, + mutationKey: [mutationKeyMock] + }) + ) + + await waitFor(() => expect(result.current.mutate).toEqual(expect.any(Function))) + + result.current.mutate('test') + + await waitFor(() => expect(result.current.isSuccess).toEqual(true)) + + expect(result.current.isIdle).toEqual(false) + expect(result.current.isPending).toEqual(false) + expect(result.current.isError).toEqual(false) + expect(result.current.isSuccess).toEqual(true) + expect(result.current.data).toEqual(safeMultisigTransaction) + expect(result.current.error).toEqual(null) + + expect(mutationSafeClientFnMock).toHaveBeenCalledTimes(1) + expect(mutationSafeClientFnMock).toHaveBeenCalledWith(signerClientMock, 'test') + + expect(createAddOwnerTxMock).toHaveBeenCalledTimes(1) + expect(createAddOwnerTxMock).toHaveBeenCalledWith('test') + }) + + it('should return error data if signer client is not connected', async () => { + useSignerClientSpy.mockReturnValueOnce(undefined) + + const { result } = renderHookInQueryClientProvider(() => + useSignerClientMutation({ + mutationSafeClientFn: mutationSafeClientFnMock, + mutationKey: [mutationKeyMock] + }) + ) + + await waitFor(() => expect(result.current.mutate).toEqual(expect.any(Function))) + + result.current.mutate('test') + + await waitFor(() => expect(result.current.isError).toEqual(true)) + + expect(result.current.isIdle).toEqual(false) + expect(result.current.isPending).toEqual(false) + expect(result.current.isError).toEqual(true) + expect(result.current.isSuccess).toEqual(false) + expect(result.current.data).toEqual(undefined) + expect(result.current.error).toEqual(new Error('Signer client is not available')) + + expect(mutationSafeClientFnMock).toHaveBeenCalledTimes(0) + expect(createAddOwnerTxMock).toHaveBeenCalledTimes(0) + }) + + it('should return error data if the request fails', async () => { + createAddOwnerTxMock.mockRejectedValueOnce(new Error('Error :(')) + + const { result } = renderHookInQueryClientProvider(() => + useSignerClientMutation({ + mutationSafeClientFn: mutationSafeClientFnMock, + mutationKey: [mutationKeyMock] + }) + ) + + await waitFor(() => expect(result.current.mutate).toEqual(expect.any(Function))) + + result.current.mutate('test') + + await waitFor(() => expect(result.current.isError).toEqual(true)) + + expect(result.current).toMatchObject({ + data: undefined, + status: 'error', + isError: true, + isSuccess: false, + isPending: false, + error: new Error('Error :(') + }) + + expect(mutationSafeClientFnMock).toHaveBeenCalledTimes(1) + expect(mutationSafeClientFnMock).toHaveBeenCalledWith(signerClientMock, 'test') + + expect(createAddOwnerTxMock).toHaveBeenCalledTimes(1) + expect(createAddOwnerTxMock).toHaveBeenCalledWith('test') + }) + }) + + describe('when calling the `mutateAsync` function', () => { + it('should call the passed `mutationSafeClientFn` function with a SafeClient instance and resolve with result', async () => { + const { result } = renderHookInQueryClientProvider(() => + useSignerClientMutation({ + mutationSafeClientFn: mutationSafeClientFnMock, + mutationKey: [mutationKeyMock] + }) + ) + + await waitFor(() => expect(result.current.mutateAsync).toEqual(expect.any(Function))) + + const sendResult = await result.current.mutateAsync('test') + + expect(sendResult).toEqual(safeMultisigTransaction) + + expect(mutationSafeClientFnMock).toHaveBeenCalledTimes(1) + expect(mutationSafeClientFnMock).toHaveBeenCalledWith(signerClientMock, 'test') + + expect(createAddOwnerTxMock).toHaveBeenCalledTimes(1) + expect(createAddOwnerTxMock).toHaveBeenCalledWith('test') + }) + + it('should return error if signer client is not connected', async () => { + useSignerClientSpy.mockReturnValueOnce(undefined) + + const { result } = renderHookInQueryClientProvider(() => + useSignerClientMutation({ + mutationSafeClientFn: mutationSafeClientFnMock, + mutationKey: [mutationKeyMock] + }) + ) + + await waitFor(() => expect(result.current.mutateAsync).toEqual(expect.any(Function))) + + await expect(() => result.current.mutateAsync('test')).rejects.toThrow( + 'Signer client is not available' + ) + + expect(mutationSafeClientFnMock).toHaveBeenCalledTimes(0) + expect(createAddOwnerTxMock).toHaveBeenCalledTimes(0) + }) + + it('should throw error if the request fails', async () => { + createAddOwnerTxMock.mockRejectedValueOnce(new Error('Error :(')) + + const { result } = renderHookInQueryClientProvider(() => + useSignerClientMutation({ + mutationSafeClientFn: mutationSafeClientFnMock, + mutationKey: [mutationKeyMock] + }) + ) + + await waitFor(() => expect(result.current.mutateAsync).toEqual(expect.any(Function))) + + await expect(() => result.current.mutateAsync('test')).rejects.toThrow('Error :(') + + expect(mutationSafeClientFnMock).toHaveBeenCalledTimes(1) + expect(mutationSafeClientFnMock).toHaveBeenCalledWith(signerClientMock, 'test') + + expect(createAddOwnerTxMock).toHaveBeenCalledTimes(1) + expect(createAddOwnerTxMock).toHaveBeenCalledWith('test') + }) + }) +}) From 7dc99c905e239c7e513018e778dd61fae5bdfdc4 Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Fri, 27 Sep 2024 17:38:15 +0200 Subject: [PATCH 13/29] refactor: Update `useUpdateThreshold` hook to use `useSignerClientMutation` --- src/hooks/useUpdateThreshold.ts | 37 +++++++++++---------------------- 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/src/hooks/useUpdateThreshold.ts b/src/hooks/useUpdateThreshold.ts index 340a679..49e62a9 100644 --- a/src/hooks/useUpdateThreshold.ts +++ b/src/hooks/useUpdateThreshold.ts @@ -1,14 +1,9 @@ -import { - UseMutateAsyncFunction, - UseMutateFunction, - useMutation, - UseMutationResult -} from '@tanstack/react-query' +import { UseMutateAsyncFunction, UseMutateFunction, UseMutationResult } from '@tanstack/react-query' import { SafeClientResult } from '@safe-global/sdk-starter-kit' import { ConfigParam, SafeConfigWithSigner } from '@/types/index.js' -import { useSignerClient } from '@/hooks/useSignerClient.js' +import { useSendTransaction } from '@/hooks/useSendTransaction.js' +import { useSignerClientMutation } from '@/hooks/useSignerClientMutation.js' import { MutationKey } from '@/constants.js' -import { useSendTransaction } from './useSendTransaction.js' type UpdateThresholdVariables = { threshold: number } @@ -35,26 +30,18 @@ export type UseUpdateThresholdReturnType = Omit< export function useUpdateThreshold( params: UseUpdateThresholdParams = {} ): UseUpdateThresholdReturnType { - const signerClient = useSignerClient({ config: params.config }) const { sendTransactionAsync } = useSendTransaction({ config: params.config }) - const mutationFn = async ({ threshold }: UpdateThresholdVariables) => { - if (!signerClient) { - throw new Error('Signer client is not available') - } - - if (threshold === 0) { - throw new Error('Threshold needs to be greater than 0') + const { mutate, mutateAsync, ...result } = useSignerClientMutation< + SafeClientResult, + UpdateThresholdVariables + >({ + ...params, + mutationKey: [MutationKey.AddOwner], + mutationSafeClientFn: async (signerClient, { threshold }) => { + const updateThresholdTx = await signerClient.protocolKit.createChangeThresholdTx(threshold) + return sendTransactionAsync({ transactions: [updateThresholdTx] }) } - - const updateThresholdTx = await signerClient.protocolKit.createChangeThresholdTx(threshold) - - return sendTransactionAsync({ transactions: [updateThresholdTx] }) - } - - const { mutate, mutateAsync, ...result } = useMutation({ - mutationFn, - mutationKey: [MutationKey.UpdateThreshold] }) return { ...result, updateThreshold: mutate, updateThresholdAsync: mutateAsync } From 0cd5e9f7e994bc74431dd303cd502e541850d186 Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Fri, 27 Sep 2024 20:19:21 +0200 Subject: [PATCH 14/29] Unit tests for `useUpdateThreshold` hook --- src/hooks/useUpdateThreshold.test.ts | 213 +++++++++++++++++++++++++++ src/hooks/useUpdateThreshold.ts | 4 +- 2 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 src/hooks/useUpdateThreshold.test.ts diff --git a/src/hooks/useUpdateThreshold.test.ts b/src/hooks/useUpdateThreshold.test.ts new file mode 100644 index 0000000..5d45d66 --- /dev/null +++ b/src/hooks/useUpdateThreshold.test.ts @@ -0,0 +1,213 @@ +import { useMutation } from '@tanstack/react-query' +import { waitFor } from '@testing-library/dom' +import { SafeClient } from '@safe-global/sdk-starter-kit' +import { useUpdateThreshold } from '@/hooks/useUpdateThreshold.js' +import * as useSendTransaction from '@/hooks/useSendTransaction.js' +import * as useSignerClientMutation from '@/hooks/useSignerClientMutation.js' +import { ethereumTxHash, safeMultisigTransaction, signerPrivateKeys } from '@test/fixtures/index.js' +import { MutationKey } from '@/constants.js' +import { configPredictedSafe } from '@test/config.js' +import { renderHookInQueryClientProvider } from '@test/utils.js' + +describe('useUpdateThreshold', () => { + const threshold = 2 + + const useSendTransactionSpy = jest.spyOn(useSendTransaction, 'useSendTransaction') + const useSignerClientMutationSpy = jest.spyOn(useSignerClientMutation, 'useSignerClientMutation') + + const updateThresholdResultMock = safeMultisigTransaction + const createChangeThresholdTxMock = jest.fn().mockResolvedValue(updateThresholdResultMock) + + const sendTransactionResultMock = ethereumTxHash + const sendTransactionAsyncMock = jest.fn().mockResolvedValue(sendTransactionResultMock) + + const signerClientMock = { + protocolKit: { createChangeThresholdTx: createChangeThresholdTxMock } + } as unknown as SafeClient + + const mutationIdleResult = { + updateThreshold: expect.any(Function), + updateThresholdAsync: expect.any(Function), + isIdle: true, + isPaused: false, + isPending: false, + isSuccess: false, + reset: expect.any(Function), + status: 'idle', + submittedAt: 0, + variables: undefined, + context: undefined, + data: undefined, + error: null, + failureCount: 0, + failureReason: null, + isError: false + } + + beforeEach(() => { + useSignerClientMutationSpy.mockImplementation( + ({ + mutationSafeClientFn, + mutationKey + }: useSignerClientMutation.UseSignerClientMutationParams< + SafeClientResult, + UpdateThresholdVariables + >) => + useMutation({ + mutationKey, + mutationFn: (params: UpdateThresholdVariables) => + mutationSafeClientFn(signerClientMock, params) + }) + ) + + useSendTransactionSpy.mockReturnValue({ + sendTransactionAsync: sendTransactionAsyncMock + } as unknown as useSendTransaction.UseSendTransactionReturnType) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should return result of `useSignerClientMutation` call with `updateThreshold` + `updateThresholdAsync` functions', () => { + const { result } = renderHookInQueryClientProvider(() => useUpdateThreshold()) + + expect(useSendTransactionSpy).toHaveBeenCalledTimes(1) + expect(useSendTransactionSpy).toHaveBeenCalledWith({ config: undefined }) + + expect(useSignerClientMutationSpy).toHaveBeenCalledTimes(1) + expect(useSignerClientMutationSpy).toHaveBeenCalledWith({ + mutationSafeClientFn: expect.any(Function), + mutationKey: [MutationKey.UpdateThreshold] + }) + + expect(result.current).toEqual(mutationIdleResult) + + expect(useSignerClientMutationSpy).toHaveBeenCalledTimes(1) + expect(useSignerClientMutationSpy).toHaveBeenCalledWith({ + mutationKey: [MutationKey.UpdateThreshold], + mutationSafeClientFn: expect.any(Function) + }) + + expect(createChangeThresholdTxMock).toHaveBeenCalledTimes(0) + expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(0) + }) + + it('should accept a config to override the one from the SafeProvider', async () => { + const config = { ...configPredictedSafe, signer: signerPrivateKeys[0] } + + const { result } = renderHookInQueryClientProvider(() => useUpdateThreshold({ config })) + + expect(useSendTransactionSpy).toHaveBeenCalledTimes(1) + expect(useSendTransactionSpy).toHaveBeenCalledWith({ config }) + + expect(useSignerClientMutationSpy).toHaveBeenCalledTimes(1) + expect(useSignerClientMutationSpy).toHaveBeenCalledWith({ + config, + mutationSafeClientFn: expect.any(Function), + mutationKey: [MutationKey.UpdateThreshold] + }) + + expect(result.current).toEqual(mutationIdleResult) + + expect(createChangeThresholdTxMock).toHaveBeenCalledTimes(0) + expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(0) + }) + + it.each<'updateThreshold' | 'updateThresholdAsync'>(['updateThreshold', 'updateThresholdAsync'])( + 'calling `%s` should create and send a transaction to change the threshold', + async (fnName) => { + const { result } = renderHookInQueryClientProvider(() => useUpdateThreshold()) + + expect(createChangeThresholdTxMock).toHaveBeenCalledTimes(0) + expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(0) + + const updateThresholdResult = await result.current[fnName]({ threshold }) + + if (fnName === 'updateThresholdAsync') { + expect(updateThresholdResult).toEqual(sendTransactionResultMock) + } + + await waitFor(() => expect(result.current.isSuccess).toBeTruthy()) + + expect(result.current.data).toEqual(sendTransactionResultMock) + + expect(createChangeThresholdTxMock).toHaveBeenCalledTimes(1) + expect(createChangeThresholdTxMock).toHaveBeenCalledWith(threshold) + + expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(1) + expect(sendTransactionAsyncMock).toHaveBeenCalledWith({ + transactions: [safeMultisigTransaction] + }) + } + ) + + describe('should return error data', () => { + const getMutationErrorResult = (error: Error) => ({ + context: undefined, + isIdle: false, + isPaused: false, + isPending: false, + isSuccess: false, + status: 'error', + data: undefined, + error, + failureCount: 1, + failureReason: error, + isError: true, + reset: expect.any(Function), + submittedAt: expect.any(Number), + variables: { threshold }, + updateThreshold: expect.any(Function), + updateThresholdAsync: expect.any(Function) + }) + + it.each<'updateThreshold' | 'updateThresholdAsync'>([ + 'updateThreshold', + 'updateThresholdAsync' + ])('if creating a transaction for updating the thresholds fails for `%s`', async (fnName) => { + const error = new Error('Error creating transaction') + + createChangeThresholdTxMock.mockRejectedValueOnce(error) + + const { result } = renderHookInQueryClientProvider(() => useUpdateThreshold()) + + if (fnName === 'updateThresholdAsync') { + await expect(result.current.updateThresholdAsync({ threshold })).rejects.toEqual(error) + } else { + result.current.updateThreshold({ threshold }) + } + + await waitFor(() => expect(result.current.isError).toBeTruthy()) + + expect(result.current).toEqual(getMutationErrorResult(error)) + + expect(createChangeThresholdTxMock).toHaveBeenCalledTimes(1) + expect(createChangeThresholdTxMock).toHaveBeenCalledWith(threshold) + + expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(0) + }) + + it('if sending the threshold update transaction fails', async () => { + const error = new Error('Error sending transaction') + + sendTransactionAsyncMock.mockRejectedValueOnce(error) + + const { result } = renderHookInQueryClientProvider(() => useUpdateThreshold()) + + result.current.updateThreshold({ threshold }) + + await waitFor(() => expect(result.current.isError).toBeTruthy()) + + expect(result.current).toEqual(getMutationErrorResult(error)) + + expect(createChangeThresholdTxMock).toHaveBeenCalledTimes(1) + expect(createChangeThresholdTxMock).toHaveBeenCalledWith(threshold) + + expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(1) + expect(sendTransactionAsyncMock).toHaveBeenCalledWith({ + transactions: [safeMultisigTransaction] + }) + }) + }) +}) diff --git a/src/hooks/useUpdateThreshold.ts b/src/hooks/useUpdateThreshold.ts index 49e62a9..8cd9e8f 100644 --- a/src/hooks/useUpdateThreshold.ts +++ b/src/hooks/useUpdateThreshold.ts @@ -5,7 +5,7 @@ import { useSendTransaction } from '@/hooks/useSendTransaction.js' import { useSignerClientMutation } from '@/hooks/useSignerClientMutation.js' import { MutationKey } from '@/constants.js' -type UpdateThresholdVariables = { threshold: number } +export type UpdateThresholdVariables = { threshold: number } export type UseUpdateThresholdParams = ConfigParam export type UseUpdateThresholdReturnType = Omit< @@ -37,7 +37,7 @@ export function useUpdateThreshold( UpdateThresholdVariables >({ ...params, - mutationKey: [MutationKey.AddOwner], + mutationKey: [MutationKey.UpdateThreshold], mutationSafeClientFn: async (signerClient, { threshold }) => { const updateThresholdTx = await signerClient.protocolKit.createChangeThresholdTx(threshold) return sendTransactionAsync({ transactions: [updateThresholdTx] }) From 2087fc45835e9e865d0bbf05462b4419e82640b6 Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Fri, 27 Sep 2024 20:54:39 +0200 Subject: [PATCH 15/29] refactor: Update `useSendTransaction` hook to use `useSignerClientMutation` --- src/hooks/useSendTransaction.test.ts | 227 +++++++++++++-------------- src/hooks/useSendTransaction.ts | 58 +++---- 2 files changed, 128 insertions(+), 157 deletions(-) diff --git a/src/hooks/useSendTransaction.test.ts b/src/hooks/useSendTransaction.test.ts index 5f3e195..ff6b9ab 100644 --- a/src/hooks/useSendTransaction.test.ts +++ b/src/hooks/useSendTransaction.test.ts @@ -1,23 +1,15 @@ import { waitForTransactionReceipt } from 'wagmi/actions' -import * as tanstackReactQuery from '@tanstack/react-query' import { waitFor } from '@testing-library/react' import { SafeClient } from '@safe-global/sdk-starter-kit' import { useSendTransaction } from '@/hooks/useSendTransaction.js' import * as useWaitForTransaction from '@/hooks/useWaitForTransaction.js' -import * as useSignerClient from '@/hooks/useSignerClient.js' +import * as useSignerClientMutation from '@/hooks/useSignerClientMutation.js' import { configExistingSafe } from '@test/config.js' import { ethereumTxHash, safeAddress, safeTxHash, signerPrivateKeys } from '@test/fixtures/index.js' import { renderHookInQueryClientProvider } from '@test/utils.js' import { MutationKey, QueryKey } from '@/constants.js' import { queryClient } from '@/queryClient.js' - -// This is necessary to set a spy on the `useMutation` function without getting the following error: -// "TypeError: Cannot redefine property: useMutation" -jest.mock('@tanstack/react-query', () => ({ - __esModule: true, - // @ts-ignore - ...jest.requireActual('@tanstack/react-query') -})) +import { useMutation } from '@tanstack/react-query' describe('useSendTransaction', () => { const transactionMock = { to: '0xABC', value: '0', data: '0x987' } @@ -36,12 +28,11 @@ describe('useSendTransaction', () => { } const useWaitForTransactionSpy = jest.spyOn(useWaitForTransaction, 'useWaitForTransaction') - const useSignerClientSpy = jest.spyOn(useSignerClient, 'useSignerClient') - const useMutationSpy = jest.spyOn(tanstackReactQuery, 'useMutation') + const useSignerClientMutationSpy = jest.spyOn(useSignerClientMutation, 'useSignerClientMutation') const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries') const sendMock = jest.fn().mockResolvedValue(sendResponseMock) - const safeClientMock = { send: sendMock } + const signerClientMock = { send: sendMock } as unknown as SafeClient const waitForTransactionReceiptMock = jest.fn( () => @@ -56,7 +47,20 @@ describe('useSendTransaction', () => { waitForTransactionIndexed: waitForTransactionIndexedMock, waitForTransactionReceipt: waitForTransactionReceiptMock }) - useSignerClientSpy.mockReturnValue(safeClientMock as unknown as SafeClient) + useSignerClientMutationSpy.mockImplementation( + ({ + mutationSafeClientFn, + mutationKey + }: useSignerClientMutation.UseSignerClientMutationParams< + SafeClientResult, + SendTransactionVariables + >) => + useMutation({ + mutationKey, + mutationFn: (params: SendTransactionVariables) => + mutationSafeClientFn(signerClientMock, params) + }) + ) }) afterEach(() => { @@ -66,12 +70,16 @@ describe('useSendTransaction', () => { it.each([ ['without config parameter', { config: undefined }], ['with config parameter', { config: { ...configExistingSafe, signer: signerPrivateKeys[0] } }] - ])('should initialize signer client correctly when being called %s', async (_label, params) => { + ])('should initialize correctly when being called %s', async (_label, params) => { const { result } = renderHookInQueryClientProvider(() => useSendTransaction(params)) await waitFor(() => expect(result.current.isIdle).toEqual(true)) - expect(useSignerClientSpy).toHaveBeenCalledTimes(1) - expect(useSignerClientSpy).toHaveBeenCalledWith(params) + expect(useSignerClientMutationSpy).toHaveBeenCalledTimes(1) + expect(useSignerClientMutationSpy).toHaveBeenCalledWith({ + ...params, + mutationKey: [MutationKey.SendTransaction], + mutationSafeClientFn: expect.any(Function) + }) expect(sendMock).toHaveBeenCalledTimes(0) }) @@ -99,44 +107,57 @@ describe('useSendTransaction', () => { sendTransactionAsync: expect.any(Function) }) - expect(useMutationSpy).toHaveBeenCalledTimes(1) - expect(useMutationSpy).toHaveBeenCalledWith({ - mutationFn: expect.any(Function), - mutationKey: [MutationKey.SendTransaction] - }) - expect(sendMock).toHaveBeenCalledTimes(0) }) - describe('sendTransaction', () => { - it('should call `send` from signer client', async () => { + it.each<'sendTransaction' | 'sendTransactionAsync'>(['sendTransaction', 'sendTransactionAsync'])( + 'calling `%s` should call `send` from signer client with the provided data', + async (fnName) => { const { result } = renderHookInQueryClientProvider(() => useSendTransaction()) - await waitFor(() => expect(result.current.sendTransaction).toEqual(expect.any(Function))) + await waitFor(() => expect(result.current[fnName]).toEqual(expect.any(Function))) - result.current.sendTransaction({ transactions: [transactionMock] }) + expect(sendMock).toHaveBeenCalledTimes(0) - await waitFor(() => expect(result.current.isSuccess).toEqual(true)) + const sendResult = await result.current[fnName]({ + transactions: [transactionMock] + }) - expect(result.current.isIdle).toEqual(false) - expect(result.current.isPending).toEqual(false) - expect(result.current.isError).toEqual(false) - expect(result.current.isSuccess).toEqual(true) - expect(result.current.data).toEqual(sendResponseMock) - expect(result.current.error).toEqual(null) + if (fnName === 'sendTransactionAsync') { + expect(sendResult).toEqual(sendResponseMock) + } + + await waitFor(() => expect(result.current.isSuccess).toBeTruthy()) + + expect(result.current).toMatchObject({ + isSuccess: true, + isIdle: false, + isPending: false, + isError: false, + data: sendResponseMock + }) expect(sendMock).toHaveBeenCalledTimes(1) expect(sendMock).toHaveBeenCalledWith({ transactions: [transactionMock] }) - }) + } + ) - it('should invalidate queries for SafeInfo, PendingTransactions + Transactions if result contains `ethereumTxHash`', async () => { + it.each<'sendTransaction' | 'sendTransactionAsync'>(['sendTransaction', 'sendTransactionAsync'])( + 'calling `%s` should invalidate queries for SafeInfo, PendingTransactions + Transactions if result contains `ethereumTxHash`', + async (fnName) => { const { result } = renderHookInQueryClientProvider(() => useSendTransaction()) - await waitFor(() => expect(result.current.sendTransaction).toEqual(expect.any(Function))) + await waitFor(() => expect(result.current[fnName]).toEqual(expect.any(Function))) + + const sendResult = await result.current[fnName]({ + transactions: [transactionMock] + }) - result.current.sendTransaction({ transactions: [transactionMock] }) + if (fnName === 'sendTransactionAsync') { + expect(sendResult).toEqual(sendResponseMock) + } - await waitFor(() => expect(result.current.isSuccess).toEqual(true)) + await waitFor(() => expect(result.current.isSuccess).toBeTruthy()) expect(waitForTransactionReceiptMock).toHaveBeenCalledTimes(1) expect(waitForTransactionReceiptMock).toHaveBeenCalledWith( @@ -166,18 +187,28 @@ describe('useSendTransaction', () => { expect(invalidateQueriesSpy).toHaveBeenNthCalledWith(8, { queryKey: [QueryKey.Transactions] }) - }) + } + ) - it('should invalidate queries for PendingTransactions if result contains `safeTxHash`', async () => { - sendMock.mockResolvedValueOnce({ ...sendResponseMock, transactions: { safeTxHash } }) + it.each<'sendTransaction' | 'sendTransactionAsync'>(['sendTransaction', 'sendTransactionAsync'])( + 'calling `%s` should invalidate queries for PendingTransactions if result contains `safeTxHash`', + async (fnName) => { + const sendResponseWithSafeTxHash = { ...sendResponseMock, transactions: { safeTxHash } } + sendMock.mockResolvedValueOnce(sendResponseWithSafeTxHash) const { result } = renderHookInQueryClientProvider(() => useSendTransaction()) - await waitFor(() => expect(result.current.sendTransaction).toEqual(expect.any(Function))) + await waitFor(() => expect(result.current[fnName]).toEqual(expect.any(Function))) - result.current.sendTransaction({ transactions: [transactionMock] }) + const sendResult = await result.current[fnName]({ + transactions: [transactionMock] + }) - await waitFor(() => expect(result.current.isSuccess).toEqual(true)) + if (fnName === 'sendTransactionAsync') { + expect(sendResult).toEqual(sendResponseWithSafeTxHash) + } + + await waitFor(() => expect(result.current.isSuccess).toBeTruthy()) expect(waitForTransactionReceiptMock).not.toHaveBeenCalled() expect(waitForTransactionIndexedMock).not.toHaveBeenCalled() @@ -186,89 +217,41 @@ describe('useSendTransaction', () => { expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: [QueryKey.PendingTransactions] }) - }) - - it('should return error if signer client is not connected', async () => { - useSignerClientSpy.mockReturnValueOnce(undefined) - - const { result } = renderHookInQueryClientProvider(() => useSendTransaction()) - - await waitFor(() => expect(result.current.sendTransaction).toEqual(expect.any(Function))) - - result.current.sendTransaction({ transactions: [transactionMock] }) - - await waitFor(() => expect(result.current.isError).toEqual(true)) - - expect(result.current.isIdle).toEqual(false) - expect(result.current.isPending).toEqual(false) - expect(result.current.isError).toEqual(true) - expect(result.current.isSuccess).toEqual(false) - expect(result.current.data).toEqual(undefined) - expect(result.current.error).toEqual(new Error('Signer client is not available')) - - expect(sendMock).toHaveBeenCalledTimes(0) - }) - - it('should return error if passed transaction list is empty', async () => { - const { result } = renderHookInQueryClientProvider(() => useSendTransaction()) - - await waitFor(() => expect(result.current.sendTransaction).toEqual(expect.any(Function))) - - result.current.sendTransaction({ transactions: [] }) - - await waitFor(() => expect(result.current.isError).toEqual(true)) - - expect(result.current.isIdle).toEqual(false) - expect(result.current.isPending).toEqual(false) - expect(result.current.isError).toEqual(true) - expect(result.current.isSuccess).toEqual(false) - expect(result.current.data).toEqual(undefined) - expect(result.current.error).toEqual(new Error('No transactions provided')) - - expect(sendMock).toHaveBeenCalledTimes(0) - }) - }) - - describe('sendTransactionAsync', () => { - it('should call `send` from signer client and resolve with result', async () => { - const { result } = renderHookInQueryClientProvider(() => useSendTransaction()) - - await waitFor(() => expect(result.current.sendTransactionAsync).toEqual(expect.any(Function))) - - const sendResult = await result.current.sendTransactionAsync({ - transactions: [transactionMock] - }) - - expect(sendResult).toEqual(sendResponseMock) - - expect(sendMock).toHaveBeenCalledTimes(1) - expect(sendMock).toHaveBeenCalledWith({ transactions: [transactionMock] }) - }) + } + ) - it('should return error if signer client is not connected', async () => { - useSignerClientSpy.mockReturnValueOnce(undefined) + it.each<'sendTransaction' | 'sendTransactionAsync'>(['sendTransaction', 'sendTransactionAsync'])( + 'calling `%s` should return error data if the `send` request fails', + async (fnName) => { + const error = new Error('Send transaction failed :(') + sendMock.mockRejectedValueOnce(error) const { result } = renderHookInQueryClientProvider(() => useSendTransaction()) - await waitFor(() => expect(result.current.sendTransactionAsync).toEqual(expect.any(Function))) - - expect(() => - result.current.sendTransactionAsync({ transactions: [transactionMock] }) - ).rejects.toThrow('Signer client is not available') - - expect(sendMock).toHaveBeenCalledTimes(0) - }) + await waitFor(() => expect(result.current[fnName]).toEqual(expect.any(Function))) - it('should return error if passed transaction list is empty', async () => { - const { result } = renderHookInQueryClientProvider(() => useSendTransaction()) + if (fnName === 'sendTransactionAsync') { + await expect(() => result.current[fnName]({ transactions: [] })).rejects.toThrow(error) + } else { + result.current[fnName]({ + transactions: [transactionMock] + }) - await waitFor(() => expect(result.current.sendTransactionAsync).toEqual(expect.any(Function))) + await waitFor(() => expect(result.current.isError).toEqual(true)) - expect(() => result.current.sendTransactionAsync({ transactions: [] })).rejects.toThrow( - 'No transactions provided' - ) + expect(result.current).toMatchObject({ + isSuccess: false, + isIdle: false, + isPending: false, + isError: true, + data: undefined, + error + }) + } - expect(sendMock).toHaveBeenCalledTimes(0) - }) - }) + expect(waitForTransactionReceiptMock).not.toHaveBeenCalled() + expect(waitForTransactionIndexedMock).not.toHaveBeenCalled() + expect(invalidateQueriesSpy).not.toHaveBeenCalled() + } + ) }) diff --git a/src/hooks/useSendTransaction.ts b/src/hooks/useSendTransaction.ts index 139f8c3..bd2e5da 100644 --- a/src/hooks/useSendTransaction.ts +++ b/src/hooks/useSendTransaction.ts @@ -1,13 +1,8 @@ -import { - UseMutateAsyncFunction, - UseMutateFunction, - useMutation, - UseMutationResult -} from '@tanstack/react-query' +import { UseMutateAsyncFunction, UseMutateFunction, UseMutationResult } from '@tanstack/react-query' import { SafeTransaction, TransactionBase } from '@safe-global/safe-core-sdk-types' import { SafeClientResult } from '@safe-global/sdk-starter-kit' import { ConfigParam, isSafeTransaction, SafeConfigWithSigner } from '@/types/index.js' -import { useSignerClient } from '@/hooks/useSignerClient.js' +import { useSignerClientMutation } from '@/hooks/useSignerClientMutation.js' import { useWaitForTransaction } from '@/hooks/useWaitForTransaction.js' import { MutationKey, QueryKey } from '@/constants.js' import { invalidateQueries } from '@/queryClient.js' @@ -40,41 +35,34 @@ export function useSendTransaction( const { waitForTransactionReceipt, waitForTransactionIndexed } = useWaitForTransaction({ config: params.config }) - const signerClient = useSignerClient({ config: params.config }) - const mutationFn = async ({ transactions = [] }: SendTransactionVariables) => { - if (!signerClient) { - throw new Error('Signer client is not available') - } - - if (!transactions.length) { - throw new Error('No transactions provided') - } + const { mutate, mutateAsync, ...result } = useSignerClientMutation< + SafeClientResult, + SendTransactionVariables + >({ + ...params, + mutationKey: [MutationKey.SendTransaction], + mutationSafeClientFn: async (signerClient, { transactions = [] }) => { + const result = await signerClient.send({ + transactions: transactions.map((tx) => + isSafeTransaction(tx) ? { to: tx.data.to, value: tx.data.value, data: tx.data.data } : tx + ) + }) - const result = await signerClient.send({ - transactions: transactions.map((tx) => - isSafeTransaction(tx) ? { to: tx.data.to, value: tx.data.value, data: tx.data.data } : tx - ) - }) + if (result.transactions?.ethereumTxHash) { + await waitForTransactionReceipt(result.transactions.ethereumTxHash) - if (result.transactions?.ethereumTxHash) { - await waitForTransactionReceipt(result.transactions.ethereumTxHash) + invalidateQueries([QueryKey.PendingTransactions, QueryKey.SafeInfo]) - invalidateQueries([QueryKey.PendingTransactions, QueryKey.SafeInfo]) + await waitForTransactionIndexed(result.transactions) - await waitForTransactionIndexed(result.transactions) + invalidateQueries([QueryKey.Transactions]) + } else if (result.transactions?.safeTxHash) { + invalidateQueries([QueryKey.PendingTransactions]) + } - invalidateQueries([QueryKey.Transactions]) - } else if (result.transactions?.safeTxHash) { - invalidateQueries([QueryKey.PendingTransactions]) + return result } - - return result - } - - const { mutate, mutateAsync, ...result } = useMutation({ - mutationFn, - mutationKey: [MutationKey.SendTransaction] }) return { ...result, sendTransaction: mutate, sendTransactionAsync: mutateAsync } From c66588c33bb2e320d96cacc78e8600595091841c Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Mon, 30 Sep 2024 10:29:18 +0200 Subject: [PATCH 16/29] refactor: Update `useConfirmTransaction` hook to use `useSignerClientMutation` --- src/hooks/useConfirmTransaction.test.ts | 235 +++++++++++------------- src/hooks/useConfirmTransaction.ts | 53 ++---- src/hooks/useSendTransaction.test.ts | 9 +- 3 files changed, 130 insertions(+), 167 deletions(-) diff --git a/src/hooks/useConfirmTransaction.test.ts b/src/hooks/useConfirmTransaction.test.ts index d1670e0..be99fd3 100644 --- a/src/hooks/useConfirmTransaction.test.ts +++ b/src/hooks/useConfirmTransaction.test.ts @@ -1,24 +1,16 @@ import { waitForTransactionReceipt } from 'wagmi/actions' -import * as tanstackReactQuery from '@tanstack/react-query' +import { useMutation } from '@tanstack/react-query' import { waitFor } from '@testing-library/react' import { SafeClient } from '@safe-global/sdk-starter-kit' import { useConfirmTransaction } from '@/hooks/useConfirmTransaction.js' import * as useWaitForTransaction from '@/hooks/useWaitForTransaction.js' -import * as useSignerClient from '@/hooks/useSignerClient.js' +import * as useSignerClientMutation from '@/hooks/useSignerClientMutation.js' import { configExistingSafe } from '@test/config.js' import { ethereumTxHash, safeAddress, safeTxHash, signerPrivateKeys } from '@test/fixtures/index.js' import { renderHookInQueryClientProvider } from '@test/utils.js' import { MutationKey, QueryKey } from '@/constants.js' import { queryClient } from '@/queryClient.js' -// This is necessary to set a spy on the `useMutation` function without getting the following error: -// "TypeError: Cannot redefine property: useMutation" -jest.mock('@tanstack/react-query', () => ({ - __esModule: true, - // @ts-ignore - ...jest.requireActual('@tanstack/react-query') -})) - describe('useConfirmTransaction', () => { const confirmResponseMock = { safeAddress: safeAddress, @@ -34,12 +26,11 @@ describe('useConfirmTransaction', () => { } const useWaitForTransactionSpy = jest.spyOn(useWaitForTransaction, 'useWaitForTransaction') - const useSignerClientSpy = jest.spyOn(useSignerClient, 'useSignerClient') - const useMutationSpy = jest.spyOn(tanstackReactQuery, 'useMutation') + const useSignerClientMutationSpy = jest.spyOn(useSignerClientMutation, 'useSignerClientMutation') const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries') const confirmMock = jest.fn().mockResolvedValue(confirmResponseMock) - const safeClientMock = { confirm: confirmMock } + const signerClientMock = { confirm: confirmMock } as unknown as SafeClient const waitForTransactionReceiptMock = jest.fn( () => @@ -54,7 +45,21 @@ describe('useConfirmTransaction', () => { waitForTransactionIndexed: waitForTransactionIndexedMock, waitForTransactionReceipt: waitForTransactionReceiptMock }) - useSignerClientSpy.mockReturnValue(safeClientMock as unknown as SafeClient) + + useSignerClientMutationSpy.mockImplementation( + ({ + mutationSafeClientFn, + mutationKey + }: useSignerClientMutation.UseSignerClientMutationParams< + SafeClientResult, + ConfirmTransactionVariables + >) => + useMutation({ + mutationKey, + mutationFn: (params: ConfirmTransactionVariables) => + mutationSafeClientFn(signerClientMock, params) + }) + ) }) afterEach(() => { @@ -68,8 +73,12 @@ describe('useConfirmTransaction', () => { const { result } = renderHookInQueryClientProvider(() => useConfirmTransaction(params)) await waitFor(() => expect(result.current.isIdle).toEqual(true)) - expect(useSignerClientSpy).toHaveBeenCalledTimes(1) - expect(useSignerClientSpy).toHaveBeenCalledWith(params) + expect(useSignerClientMutationSpy).toHaveBeenCalledTimes(1) + expect(useSignerClientMutationSpy).toHaveBeenCalledWith({ + ...params, + mutationKey: [MutationKey.ConfirmTransaction], + mutationSafeClientFn: expect.any(Function) + }) expect(confirmMock).toHaveBeenCalledTimes(0) }) @@ -97,44 +106,59 @@ describe('useConfirmTransaction', () => { confirmTransactionAsync: expect.any(Function) }) - expect(useMutationSpy).toHaveBeenCalledTimes(1) - expect(useMutationSpy).toHaveBeenCalledWith({ - mutationFn: expect.any(Function), - mutationKey: [MutationKey.ConfirmTransaction] - }) - expect(confirmMock).toHaveBeenCalledTimes(0) }) - describe('confirmTransaction', () => { - it('should call `cofirm` from signer client', async () => { + it.each<'confirmTransaction' | 'confirmTransactionAsync'>([ + 'confirmTransaction', + 'confirmTransactionAsync' + ])( + 'calling `%s` should call `confirm` from signer client with the provided safeTxHash', + async (fnName) => { const { result } = renderHookInQueryClientProvider(() => useConfirmTransaction()) - await waitFor(() => expect(result.current.confirmTransaction).toEqual(expect.any(Function))) + await waitFor(() => expect(result.current[fnName]).toEqual(expect.any(Function))) - result.current.confirmTransaction({ safeTxHash }) + expect(confirmMock).toHaveBeenCalledTimes(0) + + const confirmResult = await result.current[fnName]({ safeTxHash }) - await waitFor(() => expect(result.current.isSuccess).toEqual(true)) + if (fnName === 'confirmTransactionAsync') { + expect(confirmResult).toEqual(confirmResponseMock) + } - expect(result.current.isIdle).toEqual(false) - expect(result.current.isPending).toEqual(false) - expect(result.current.isError).toEqual(false) - expect(result.current.isSuccess).toEqual(true) - expect(result.current.data).toEqual(confirmResponseMock) - expect(result.current.error).toEqual(null) + await waitFor(() => expect(result.current.isSuccess).toBeTruthy()) + + expect(result.current).toMatchObject({ + isSuccess: true, + isIdle: false, + isPending: false, + isError: false, + data: confirmResponseMock + }) expect(confirmMock).toHaveBeenCalledTimes(1) expect(confirmMock).toHaveBeenCalledWith({ safeTxHash }) - }) + } + ) - it('should invalidate queries for SafeInfo, PendingTransactions + Transactions if result contains `ethereumTxHash`', async () => { + it.each<'confirmTransaction' | 'confirmTransactionAsync'>([ + 'confirmTransaction', + 'confirmTransactionAsync' + ])( + 'calling `%s` should invalidate queries for SafeInfo, PendingTransactions + Transactions if result contains `ethereumTxHash`', + async (fnName) => { const { result } = renderHookInQueryClientProvider(() => useConfirmTransaction()) - await waitFor(() => expect(result.current.confirmTransaction).toEqual(expect.any(Function))) + await waitFor(() => expect(result.current[fnName]).toEqual(expect.any(Function))) - result.current.confirmTransaction({ safeTxHash }) + const confirmResult = await result.current[fnName]({ safeTxHash }) - await waitFor(() => expect(result.current.isSuccess).toEqual(true)) + if (fnName === 'confirmTransactionAsync') { + expect(confirmResult).toEqual(confirmResponseMock) + } + + await waitFor(() => expect(result.current.isSuccess).toBeTruthy()) expect(waitForTransactionReceiptMock).toHaveBeenCalledTimes(1) expect(waitForTransactionReceiptMock).toHaveBeenCalledWith( @@ -146,12 +170,6 @@ describe('useConfirmTransaction', () => { expect(invalidateQueriesSpy).toHaveBeenCalledTimes(8) - expect(invalidateQueriesSpy).toHaveBeenNthCalledWith(1, { - queryKey: [QueryKey.PendingTransactions] - }) - expect(invalidateQueriesSpy).toHaveBeenNthCalledWith(2, { - queryKey: [QueryKey.SafeInfo] - }) expect(invalidateQueriesSpy).toHaveBeenNthCalledWith(3, { queryKey: [QueryKey.Address] }) @@ -170,18 +188,29 @@ describe('useConfirmTransaction', () => { expect(invalidateQueriesSpy).toHaveBeenNthCalledWith(8, { queryKey: [QueryKey.Transactions] }) - }) + } + ) - it('should invalidate queries for PendingTransactions if result contains `safeTxHash`', async () => { - confirmMock.mockResolvedValueOnce({ ...confirmResponseMock, transactions: { safeTxHash } }) + it.each<'confirmTransaction' | 'confirmTransactionAsync'>([ + 'confirmTransaction', + 'confirmTransactionAsync' + ])( + 'calling `%s` should invalidate queries for PendingTransactions if result contains `safeTxHash`', + async (fnName) => { + const confirmResponseWithSafeTxHash = { ...confirmResponseMock, transactions: { safeTxHash } } + confirmMock.mockResolvedValueOnce(confirmResponseWithSafeTxHash) const { result } = renderHookInQueryClientProvider(() => useConfirmTransaction()) - await waitFor(() => expect(result.current.confirmTransaction).toEqual(expect.any(Function))) + await waitFor(() => expect(result.current[fnName]).toEqual(expect.any(Function))) - result.current.confirmTransaction({ safeTxHash }) + const confirmResult = await result.current[fnName]({ safeTxHash }) - await waitFor(() => expect(result.current.isSuccess).toEqual(true)) + if (fnName === 'confirmTransactionAsync') { + expect(confirmResult).toEqual(confirmResponseWithSafeTxHash) + } + + await waitFor(() => expect(result.current.isSuccess).toBeTruthy()) expect(waitForTransactionReceiptMock).not.toHaveBeenCalled() expect(waitForTransactionIndexedMock).not.toHaveBeenCalled() @@ -190,93 +219,39 @@ describe('useConfirmTransaction', () => { expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: [QueryKey.PendingTransactions] }) - }) - - it('should return error if signer client is not connected', async () => { - useSignerClientSpy.mockReturnValueOnce(undefined) - - const { result } = renderHookInQueryClientProvider(() => useConfirmTransaction()) - - await waitFor(() => expect(result.current.confirmTransaction).toEqual(expect.any(Function))) - - result.current.confirmTransaction({ safeTxHash }) - - await waitFor(() => expect(result.current.isError).toEqual(true)) - - expect(result.current.isIdle).toEqual(false) - expect(result.current.isPending).toEqual(false) - expect(result.current.isError).toEqual(true) - expect(result.current.isSuccess).toEqual(false) - expect(result.current.data).toEqual(undefined) - expect(result.current.error).toEqual(new Error('Signer client is not available')) + } + ) - expect(confirmMock).toHaveBeenCalledTimes(0) - }) + it.each<'confirmTransaction' | 'confirmTransactionAsync'>([ + 'confirmTransaction', + 'confirmTransactionAsync' + ])('calling `%s` should return error data if the `confirm` request fails', async (fnName) => { + const error = new Error('Confirm transaction failed :(') + confirmMock.mockRejectedValueOnce(error) - it('should return error if passed `safeTxHash` is an empty string', async () => { - const { result } = renderHookInQueryClientProvider(() => useConfirmTransaction()) + const { result } = renderHookInQueryClientProvider(() => useConfirmTransaction()) - await waitFor(() => expect(result.current.confirmTransaction).toEqual(expect.any(Function))) + await waitFor(() => expect(result.current[fnName]).toEqual(expect.any(Function))) - result.current.confirmTransaction({ safeTxHash: '' }) + if (fnName === 'confirmTransactionAsync') { + await expect(() => result.current[fnName]({ safeTxHash })).rejects.toThrow(error) + } else { + result.current[fnName]({ safeTxHash }) await waitFor(() => expect(result.current.isError).toEqual(true)) - expect(result.current.isIdle).toEqual(false) - expect(result.current.isPending).toEqual(false) - expect(result.current.isError).toEqual(true) - expect(result.current.isSuccess).toEqual(false) - expect(result.current.data).toEqual(undefined) - expect(result.current.error).toEqual(new Error('`safeTxHash` parameter must not be empty')) - - expect(confirmMock).toHaveBeenCalledTimes(0) - }) - }) - - describe('confirmTransactionAsync', () => { - it('should call `confirm` from signer client and resolve with result', async () => { - const { result } = renderHookInQueryClientProvider(() => useConfirmTransaction()) - - await waitFor(() => - expect(result.current.confirmTransactionAsync).toEqual(expect.any(Function)) - ) - - const sendResult = await result.current.confirmTransactionAsync({ safeTxHash }) - - expect(sendResult).toEqual(confirmResponseMock) - - expect(confirmMock).toHaveBeenCalledTimes(1) - expect(confirmMock).toHaveBeenCalledWith({ safeTxHash }) - }) - - it('should return error if signer client is not connected', async () => { - useSignerClientSpy.mockReturnValueOnce(undefined) - - const { result } = renderHookInQueryClientProvider(() => useConfirmTransaction()) - - await waitFor(() => - expect(result.current.confirmTransactionAsync).toEqual(expect.any(Function)) - ) - - expect(() => result.current.confirmTransactionAsync({ safeTxHash })).rejects.toThrow( - 'Signer client is not available' - ) - - expect(confirmMock).toHaveBeenCalledTimes(0) - }) - - it('should return error if passed `safeTxHash` is an empty string', async () => { - const { result } = renderHookInQueryClientProvider(() => useConfirmTransaction()) - - await waitFor(() => - expect(result.current.confirmTransactionAsync).toEqual(expect.any(Function)) - ) - - expect(() => result.current.confirmTransactionAsync({ safeTxHash: '' })).rejects.toThrow( - '`safeTxHash` parameter must not be empty' - ) + expect(result.current).toMatchObject({ + isSuccess: false, + isIdle: false, + isPending: false, + isError: true, + data: undefined, + error + }) + } - expect(confirmMock).toHaveBeenCalledTimes(0) - }) + expect(waitForTransactionReceiptMock).not.toHaveBeenCalled() + expect(waitForTransactionIndexedMock).not.toHaveBeenCalled() + expect(invalidateQueriesSpy).not.toHaveBeenCalled() }) }) diff --git a/src/hooks/useConfirmTransaction.ts b/src/hooks/useConfirmTransaction.ts index c180fc6..2a54ed2 100644 --- a/src/hooks/useConfirmTransaction.ts +++ b/src/hooks/useConfirmTransaction.ts @@ -1,17 +1,12 @@ -import { - UseMutateAsyncFunction, - UseMutateFunction, - useMutation, - UseMutationResult -} from '@tanstack/react-query' +import { UseMutateAsyncFunction, UseMutateFunction, UseMutationResult } from '@tanstack/react-query' import { SafeClientResult } from '@safe-global/sdk-starter-kit' import { ConfigParam, SafeConfigWithSigner } from '@/types/index.js' -import { useSignerClient } from '@/hooks/useSignerClient.js' +import { useSignerClientMutation } from '@/hooks/useSignerClientMutation.js' import { useWaitForTransaction } from '@/hooks/useWaitForTransaction.js' import { MutationKey, QueryKey } from '@/constants.js' import { invalidateQueries } from '@/queryClient.js' -type ConfirmTransactionVariables = { safeTxHash: string } +export type ConfirmTransactionVariables = { safeTxHash: string } export type UseConfirmTransactionParams = ConfigParam export type UseConfirmTransactionReturnType = Omit< @@ -45,37 +40,29 @@ export function useConfirmTransaction( config: params.config }) - const signerClient = useSignerClient({ config: params.config }) - - const mutationFn = async ({ safeTxHash }: ConfirmTransactionVariables) => { - if (!signerClient) { - throw new Error('Signer client is not available') - } - - if (!safeTxHash.length) { - throw new Error('`safeTxHash` parameter must not be empty') - } + const { mutate, mutateAsync, ...result } = useSignerClientMutation< + SafeClientResult, + ConfirmTransactionVariables + >({ + ...params, + mutationKey: [MutationKey.ConfirmTransaction], + mutationSafeClientFn: async (signerClient, { safeTxHash }) => { + const result = await signerClient.confirm({ safeTxHash }) - const result = await signerClient.confirm({ safeTxHash }) + if (result.transactions?.ethereumTxHash) { + await waitForTransactionReceipt(result.transactions.ethereumTxHash) - if (result.transactions?.ethereumTxHash) { - await waitForTransactionReceipt(result.transactions.ethereumTxHash) + invalidateQueries([QueryKey.PendingTransactions, QueryKey.SafeInfo]) - invalidateQueries([QueryKey.PendingTransactions, QueryKey.SafeInfo]) + await waitForTransactionIndexed(result.transactions) - await waitForTransactionIndexed(result.transactions) + invalidateQueries([QueryKey.Transactions]) + } else if (result.transactions?.safeTxHash) { + invalidateQueries([QueryKey.PendingTransactions]) + } - invalidateQueries([QueryKey.Transactions]) - } else if (result.transactions?.safeTxHash) { - invalidateQueries([QueryKey.PendingTransactions]) + return result } - - return result - } - - const { mutate, mutateAsync, ...result } = useMutation({ - mutationFn, - mutationKey: [MutationKey.ConfirmTransaction] }) return { ...result, confirmTransaction: mutate, confirmTransactionAsync: mutateAsync } diff --git a/src/hooks/useSendTransaction.test.ts b/src/hooks/useSendTransaction.test.ts index ff6b9ab..ba3a6a5 100644 --- a/src/hooks/useSendTransaction.test.ts +++ b/src/hooks/useSendTransaction.test.ts @@ -47,6 +47,7 @@ describe('useSendTransaction', () => { waitForTransactionIndexed: waitForTransactionIndexedMock, waitForTransactionReceipt: waitForTransactionReceiptMock }) + useSignerClientMutationSpy.mockImplementation( ({ mutationSafeClientFn, @@ -231,11 +232,11 @@ describe('useSendTransaction', () => { await waitFor(() => expect(result.current[fnName]).toEqual(expect.any(Function))) if (fnName === 'sendTransactionAsync') { - await expect(() => result.current[fnName]({ transactions: [] })).rejects.toThrow(error) + await expect(() => + result.current[fnName]({ transactions: [transactionMock] }) + ).rejects.toThrow(error) } else { - result.current[fnName]({ - transactions: [transactionMock] - }) + result.current[fnName]({ transactions: [transactionMock] }) await waitFor(() => expect(result.current.isError).toEqual(true)) From 19d24b1ba118a2bf470b6cdb3b0e53dd6ba62888 Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Tue, 1 Oct 2024 15:23:59 +0200 Subject: [PATCH 17/29] Add unit tests for `useUpdateOwners` hook Also add fixtures for mutation result objects --- src/hooks/useUpdateOwners/useAddOwner.test.ts | 202 ++++++++++++++++++ test/fixtures/mutationResult.ts | 102 +++++++++ 2 files changed, 304 insertions(+) create mode 100644 src/hooks/useUpdateOwners/useAddOwner.test.ts create mode 100644 test/fixtures/mutationResult.ts diff --git a/src/hooks/useUpdateOwners/useAddOwner.test.ts b/src/hooks/useUpdateOwners/useAddOwner.test.ts new file mode 100644 index 0000000..01040a1 --- /dev/null +++ b/src/hooks/useUpdateOwners/useAddOwner.test.ts @@ -0,0 +1,202 @@ +import { useMutation } from '@tanstack/react-query' +import { waitFor } from '@testing-library/dom' +import { SafeClient } from '@safe-global/sdk-starter-kit' +import * as useSendTransaction from '@/hooks/useSendTransaction.js' +import * as useSignerClientMutation from '@/hooks/useSignerClientMutation.js' +import { useAddOwner } from '@/hooks/useUpdateOwners/useAddOwner.js' +import { + accounts, + ethereumTxHash, + safeMultisigTransaction, + signerPrivateKeys +} from '@test/fixtures/index.js' +import { configPredictedSafe } from '@test/config.js' +import { getCustomMutationResult } from '@test/fixtures/mutationResult.js' +import { renderHookInQueryClientProvider } from '@test/utils.js' +import { MutationKey } from '@/constants.js' + +describe('useAddOwner', () => { + const ownerAddress = accounts[1] + + const mutateFnName = 'addOwner' + const variables = { ownerAddress } + const mutationIdleResult = getCustomMutationResult({ status: 'idle', mutateFnName }) + const mutationSuccessResult = getCustomMutationResult({ + status: 'success', + mutateFnName, + data: ethereumTxHash, + variables + }) + + const useSendTransactionSpy = jest.spyOn(useSendTransaction, 'useSendTransaction') + const useSignerClientMutationSpy = jest.spyOn(useSignerClientMutation, 'useSignerClientMutation') + + const createAddOwnerTxResultMock = safeMultisigTransaction + const createAddOwnerTxMock = jest.fn().mockResolvedValue(createAddOwnerTxResultMock) + + const sendTransactionResultMock = ethereumTxHash + const sendTransactionAsyncMock = jest.fn().mockResolvedValue(sendTransactionResultMock) + + const signerClientMock = { + protocolKit: { createAddOwnerTx: createAddOwnerTxMock } + } as unknown as SafeClient + + beforeEach(() => { + useSignerClientMutationSpy.mockImplementation( + ({ + mutationSafeClientFn, + mutationKey + }: useSignerClientMutation.UseSignerClientMutationParams< + SafeClientResult, + AddOwnerVariables + >) => + useMutation({ + mutationKey, + mutationFn: (params: AddOwnerVariables) => mutationSafeClientFn(signerClientMock, params) + }) + ) + + useSendTransactionSpy.mockReturnValue({ + sendTransactionAsync: sendTransactionAsyncMock + } as unknown as useSendTransaction.UseSendTransactionReturnType) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should return result of `useSignerClientMutation` call with `addOwner` + `addOwnerAsync` functions', () => { + const { result } = renderHookInQueryClientProvider(() => useAddOwner()) + + expect(useSendTransactionSpy).toHaveBeenCalledTimes(1) + expect(useSendTransactionSpy).toHaveBeenCalledWith({ config: undefined }) + + expect(useSignerClientMutationSpy).toHaveBeenCalledTimes(1) + expect(useSignerClientMutationSpy).toHaveBeenCalledWith({ + mutationSafeClientFn: expect.any(Function), + mutationKey: [MutationKey.AddOwner] + }) + + expect(result.current).toEqual(mutationIdleResult) + + expect(useSignerClientMutationSpy).toHaveBeenCalledTimes(1) + expect(useSignerClientMutationSpy).toHaveBeenCalledWith({ + mutationKey: [MutationKey.AddOwner], + mutationSafeClientFn: expect.any(Function) + }) + + expect(createAddOwnerTxMock).toHaveBeenCalledTimes(0) + expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(0) + }) + + it('should accept a config to override the one from the SafeProvider', async () => { + const config = { ...configPredictedSafe, signer: signerPrivateKeys[0] } + + const { result } = renderHookInQueryClientProvider(() => useAddOwner({ config })) + + expect(useSendTransactionSpy).toHaveBeenCalledTimes(1) + expect(useSendTransactionSpy).toHaveBeenCalledWith({ config }) + + expect(useSignerClientMutationSpy).toHaveBeenCalledTimes(1) + expect(useSignerClientMutationSpy).toHaveBeenCalledWith({ + config, + mutationSafeClientFn: expect.any(Function), + mutationKey: [MutationKey.AddOwner] + }) + + expect(result.current).toEqual(mutationIdleResult) + + expect(createAddOwnerTxMock).toHaveBeenCalledTimes(0) + expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(0) + }) + + it.each<'addOwner' | 'addOwnerAsync'>(['addOwner', 'addOwnerAsync'])( + 'calling `%s` should create and send a transaction to add an owner', + async (fnName) => { + const { result } = renderHookInQueryClientProvider(() => useAddOwner()) + + expect(createAddOwnerTxMock).toHaveBeenCalledTimes(0) + expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(0) + + const addOwnerResult = await result.current[fnName](variables) + + if (fnName === 'addOwnerAsync') { + expect(addOwnerResult).toEqual(sendTransactionResultMock) + } + + await waitFor(() => expect(result.current.isSuccess).toBeTruthy()) + + expect(result.current).toEqual(mutationSuccessResult) + + expect(createAddOwnerTxMock).toHaveBeenCalledTimes(1) + expect(createAddOwnerTxMock).toHaveBeenCalledWith(variables) + + expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(1) + expect(sendTransactionAsyncMock).toHaveBeenCalledWith({ + transactions: [createAddOwnerTxResultMock] + }) + } + ) + + describe('should return error data', () => { + it.each<'addOwner' | 'addOwnerAsync'>(['addOwner', 'addOwnerAsync'])( + 'if creating a transaction for adding an owner fails for `%s`', + async (fnName) => { + const error = new Error('Error creating transaction') + const mutationErrorResult = getCustomMutationResult({ + status: 'error', + mutateFnName, + error, + variables + }) + + createAddOwnerTxMock.mockRejectedValueOnce(error) + + const { result } = renderHookInQueryClientProvider(() => useAddOwner()) + + if (fnName === 'addOwnerAsync') { + await expect(result.current.addOwnerAsync(variables)).rejects.toEqual(error) + } else { + result.current.addOwner(variables) + } + + await waitFor(() => expect(result.current.isError).toBeTruthy()) + + expect(result.current).toEqual(mutationErrorResult) + + expect(createAddOwnerTxMock).toHaveBeenCalledTimes(1) + expect(createAddOwnerTxMock).toHaveBeenCalledWith(variables) + + expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(0) + } + ) + + it('if sending the threshold add owner transaction fails', async () => { + const error = new Error('Error sending transaction') + const mutationErrorResult = getCustomMutationResult({ + status: 'error', + mutateFnName, + error, + variables + }) + + sendTransactionAsyncMock.mockRejectedValueOnce(error) + + const { result } = renderHookInQueryClientProvider(() => useAddOwner()) + + result.current.addOwner(variables) + + await waitFor(() => expect(result.current.isError).toBeTruthy()) + + expect(result.current).toEqual(mutationErrorResult) + + expect(createAddOwnerTxMock).toHaveBeenCalledTimes(1) + expect(createAddOwnerTxMock).toHaveBeenCalledWith(variables) + + expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(1) + expect(sendTransactionAsyncMock).toHaveBeenCalledWith({ + transactions: [createAddOwnerTxResultMock] + }) + }) + }) +}) diff --git a/test/fixtures/mutationResult.ts b/test/fixtures/mutationResult.ts new file mode 100644 index 0000000..3769a39 --- /dev/null +++ b/test/fixtures/mutationResult.ts @@ -0,0 +1,102 @@ +import { + MutationObserverErrorResult, + MutationObserverIdleResult, + MutationObserverLoadingResult, + MutationObserverSuccessResult +} from '@tanstack/react-query' + +export const mutationIdleResult: MutationObserverIdleResult = { + mutate: expect.any(Function), + isIdle: true, + isPaused: false, + isPending: false, + isSuccess: false, + reset: expect.any(Function), + status: 'idle', + submittedAt: 0, + variables: undefined, + context: undefined, + data: undefined, + error: null, + failureCount: 0, + failureReason: null, + isError: false +} + +export const mutationPendingResult: MutationObserverLoadingResult = { + ...mutationIdleResult, + isIdle: false, + isPending: true, + status: 'pending' +} + +export const mutationSuccessResult: MutationObserverSuccessResult = { + ...mutationIdleResult, + isIdle: false, + isSuccess: true, + status: 'success', + submittedAt: expect.any(Number) +} + +export const mutationErrorResult: MutationObserverErrorResult = { + ...mutationIdleResult, + isIdle: false, + isError: true, + status: 'error', + failureCount: 1, + submittedAt: expect.any(Number), + variables: expect.any(Object), + error: new Error('Something went wrong :('), + failureReason: new Error('Something went wrong :(') +} + +const resultMapping = { + idle: mutationIdleResult, + pending: mutationPendingResult, + success: mutationSuccessResult, + error: mutationErrorResult +} + +export function getCustomMutationResult< + TStatus extends keyof typeof resultMapping, + TMutateFnName extends string, + TResult = (typeof resultMapping)[TStatus], + TData = TStatus extends 'success' ? unknown : undefined, + TError = TStatus extends 'error' ? Error : undefined, + TVariables = TStatus extends 'error' | 'success' ? unknown : undefined +>({ + status, + mutateFnName, + data, + error, + variables +}: { + status: TStatus + mutateFnName: TMutateFnName + data?: TData + error?: TError + variables?: TVariables +}) { + const { mutate, ...result } = resultMapping[status] + return { + ...result, + [mutateFnName]: mutate, + [`${mutateFnName}Async`]: mutate, + ...(status === 'success' && data ? { data, variables } : {}), + ...(status === 'error' && error ? { error, failureReason: error, variables } : {}) + } as TResult & { + [k in TMutateFnName]: typeof mutate + } & { + [key in `${TMutateFnName}Async`]: typeof mutate + } & (TStatus extends 'error' + ? { + error: TError + failureReason: TError + } + : {}) & + (TStatus extends 'success' + ? { + data: TData + } + : {}) +} From 31fc54c716f86a95edd830efa12a769fd3136ee0 Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Tue, 1 Oct 2024 15:25:13 +0200 Subject: [PATCH 18/29] Refactor tests to use mutation result object fixtures that were added in previous commit --- src/hooks/useConfirmTransaction.test.ts | 57 ++++++-------- src/hooks/useSendTransaction.test.ts | 68 +++++++---------- src/hooks/useSignerClientMutation.test.ts | 92 +++++++++++------------ src/hooks/useUpdateThreshold.test.ts | 79 ++++++++----------- 4 files changed, 126 insertions(+), 170 deletions(-) diff --git a/src/hooks/useConfirmTransaction.test.ts b/src/hooks/useConfirmTransaction.test.ts index be99fd3..d25574c 100644 --- a/src/hooks/useConfirmTransaction.test.ts +++ b/src/hooks/useConfirmTransaction.test.ts @@ -7,6 +7,7 @@ import * as useWaitForTransaction from '@/hooks/useWaitForTransaction.js' import * as useSignerClientMutation from '@/hooks/useSignerClientMutation.js' import { configExistingSafe } from '@test/config.js' import { ethereumTxHash, safeAddress, safeTxHash, signerPrivateKeys } from '@test/fixtures/index.js' +import { getCustomMutationResult } from '@test/fixtures/mutationResult.js' import { renderHookInQueryClientProvider } from '@test/utils.js' import { MutationKey, QueryKey } from '@/constants.js' import { queryClient } from '@/queryClient.js' @@ -25,6 +26,19 @@ describe('useConfirmTransaction', () => { safeAccountDeployment: undefined } + const mutateFnName = 'confirmTransaction' + const variables = { safeTxHash } + const mutationIdleResult = getCustomMutationResult({ + status: 'idle', + mutateFnName + }) + const mutationSuccessResult = getCustomMutationResult({ + status: 'success', + mutateFnName, + data: confirmResponseMock, + variables + }) + const useWaitForTransactionSpy = jest.spyOn(useWaitForTransaction, 'useWaitForTransaction') const useSignerClientMutationSpy = jest.spyOn(useSignerClientMutation, 'useSignerClientMutation') const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries') @@ -87,24 +101,7 @@ describe('useConfirmTransaction', () => { const { result } = renderHookInQueryClientProvider(() => useConfirmTransaction()) await waitFor(() => expect(result.current.isIdle).toEqual(true)) - expect(result.current).toEqual({ - context: undefined, - data: undefined, - error: null, - failureCount: 0, - failureReason: null, - isPaused: false, - status: 'idle', - variables: undefined, - submittedAt: 0, - isPending: false, - isSuccess: false, - isError: false, - isIdle: true, - reset: expect.any(Function), - confirmTransaction: expect.any(Function), - confirmTransactionAsync: expect.any(Function) - }) + expect(result.current).toEqual(mutationIdleResult) expect(confirmMock).toHaveBeenCalledTimes(0) }) @@ -129,13 +126,7 @@ describe('useConfirmTransaction', () => { await waitFor(() => expect(result.current.isSuccess).toBeTruthy()) - expect(result.current).toMatchObject({ - isSuccess: true, - isIdle: false, - isPending: false, - isError: false, - data: confirmResponseMock - }) + expect(result.current).toEqual(mutationSuccessResult) expect(confirmMock).toHaveBeenCalledTimes(1) expect(confirmMock).toHaveBeenCalledWith({ safeTxHash }) @@ -227,6 +218,13 @@ describe('useConfirmTransaction', () => { 'confirmTransactionAsync' ])('calling `%s` should return error data if the `confirm` request fails', async (fnName) => { const error = new Error('Confirm transaction failed :(') + const mutationErrorResult = getCustomMutationResult({ + status: 'error', + mutateFnName, + error, + variables + }) + confirmMock.mockRejectedValueOnce(error) const { result } = renderHookInQueryClientProvider(() => useConfirmTransaction()) @@ -240,14 +238,7 @@ describe('useConfirmTransaction', () => { await waitFor(() => expect(result.current.isError).toEqual(true)) - expect(result.current).toMatchObject({ - isSuccess: false, - isIdle: false, - isPending: false, - isError: true, - data: undefined, - error - }) + expect(result.current).toEqual(mutationErrorResult) } expect(waitForTransactionReceiptMock).not.toHaveBeenCalled() diff --git a/src/hooks/useSendTransaction.test.ts b/src/hooks/useSendTransaction.test.ts index ba3a6a5..cb7c843 100644 --- a/src/hooks/useSendTransaction.test.ts +++ b/src/hooks/useSendTransaction.test.ts @@ -6,6 +6,7 @@ import * as useWaitForTransaction from '@/hooks/useWaitForTransaction.js' import * as useSignerClientMutation from '@/hooks/useSignerClientMutation.js' import { configExistingSafe } from '@test/config.js' import { ethereumTxHash, safeAddress, safeTxHash, signerPrivateKeys } from '@test/fixtures/index.js' +import { getCustomMutationResult } from '@test/fixtures/mutationResult.js' import { renderHookInQueryClientProvider } from '@test/utils.js' import { MutationKey, QueryKey } from '@/constants.js' import { queryClient } from '@/queryClient.js' @@ -27,6 +28,20 @@ describe('useSendTransaction', () => { safeAccountDeployment: undefined } + const mutateFnName = 'sendTransaction' + const variables = { transactions: [transactionMock] } + + const mutationIdleResult = getCustomMutationResult({ + status: 'idle', + mutateFnName + }) + const mutationSuccessResult = getCustomMutationResult({ + status: 'success', + mutateFnName, + data: sendResponseMock, + variables + }) + const useWaitForTransactionSpy = jest.spyOn(useWaitForTransaction, 'useWaitForTransaction') const useSignerClientMutationSpy = jest.spyOn(useSignerClientMutation, 'useSignerClientMutation') const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries') @@ -89,24 +104,7 @@ describe('useSendTransaction', () => { const { result } = renderHookInQueryClientProvider(() => useSendTransaction()) await waitFor(() => expect(result.current.isIdle).toEqual(true)) - expect(result.current).toEqual({ - context: undefined, - data: undefined, - error: null, - failureCount: 0, - failureReason: null, - isPaused: false, - status: 'idle', - variables: undefined, - submittedAt: 0, - isPending: false, - isSuccess: false, - isError: false, - isIdle: true, - reset: expect.any(Function), - sendTransaction: expect.any(Function), - sendTransactionAsync: expect.any(Function) - }) + expect(result.current).toEqual(mutationIdleResult) expect(sendMock).toHaveBeenCalledTimes(0) }) @@ -120,9 +118,7 @@ describe('useSendTransaction', () => { expect(sendMock).toHaveBeenCalledTimes(0) - const sendResult = await result.current[fnName]({ - transactions: [transactionMock] - }) + const sendResult = await result.current[fnName](variables) if (fnName === 'sendTransactionAsync') { expect(sendResult).toEqual(sendResponseMock) @@ -130,13 +126,7 @@ describe('useSendTransaction', () => { await waitFor(() => expect(result.current.isSuccess).toBeTruthy()) - expect(result.current).toMatchObject({ - isSuccess: true, - isIdle: false, - isPending: false, - isError: false, - data: sendResponseMock - }) + expect(result.current).toEqual(mutationSuccessResult) expect(sendMock).toHaveBeenCalledTimes(1) expect(sendMock).toHaveBeenCalledWith({ transactions: [transactionMock] }) @@ -225,6 +215,13 @@ describe('useSendTransaction', () => { 'calling `%s` should return error data if the `send` request fails', async (fnName) => { const error = new Error('Send transaction failed :(') + const mutationErrorResult = getCustomMutationResult({ + status: 'error', + mutateFnName, + error, + variables + }) + sendMock.mockRejectedValueOnce(error) const { result } = renderHookInQueryClientProvider(() => useSendTransaction()) @@ -232,22 +229,13 @@ describe('useSendTransaction', () => { await waitFor(() => expect(result.current[fnName]).toEqual(expect.any(Function))) if (fnName === 'sendTransactionAsync') { - await expect(() => - result.current[fnName]({ transactions: [transactionMock] }) - ).rejects.toThrow(error) + await expect(() => result.current[fnName](variables)).rejects.toThrow(error) } else { - result.current[fnName]({ transactions: [transactionMock] }) + result.current[fnName](variables) await waitFor(() => expect(result.current.isError).toEqual(true)) - expect(result.current).toMatchObject({ - isSuccess: false, - isIdle: false, - isPending: false, - isError: true, - data: undefined, - error - }) + expect(result.current).toEqual(mutationErrorResult) } expect(waitForTransactionReceiptMock).not.toHaveBeenCalled() diff --git a/src/hooks/useSignerClientMutation.test.ts b/src/hooks/useSignerClientMutation.test.ts index cbae888..c75b646 100644 --- a/src/hooks/useSignerClientMutation.test.ts +++ b/src/hooks/useSignerClientMutation.test.ts @@ -6,6 +6,7 @@ import * as useSignerClient from '@/hooks/useSignerClient.js' import * as useConfig from '@/hooks/useConfig.js' import { configExistingSafe } from '@test/config.js' import { safeMultisigTransaction, signerPrivateKeys } from '@test/fixtures/index.js' +import { getCustomMutationResult } from '@test/fixtures/mutationResult.js' import { renderHookInQueryClientProvider } from '@test/utils.js' // This is necessary to set a spy on the `useMutation` function without getting the following error: @@ -23,6 +24,15 @@ describe('useSignerClientMutation', () => { const createAddOwnerTxMock = jest.fn().mockResolvedValue(safeMultisigTransaction) + const variables = 'test' + const mutationIdleResult = getCustomMutationResult({ status: 'idle', mutateFnName: 'mutate' }) + const mutationSuccessResult = getCustomMutationResult({ + status: 'success', + mutateFnName: 'mutate', + data: safeMultisigTransaction, + variables + }) + const signerClientMock = { protocolKit: { createAddOwnerTx: createAddOwnerTxMock } } const mutationKeyMock = 'test-mutation-key' @@ -71,24 +81,7 @@ describe('useSignerClientMutation', () => { ) await waitFor(() => expect(result.current.isIdle).toEqual(true)) - expect(result.current).toEqual({ - context: undefined, - data: undefined, - error: null, - failureCount: 0, - failureReason: null, - isPaused: false, - status: 'idle', - variables: undefined, - submittedAt: 0, - isPending: false, - isSuccess: false, - isError: false, - isIdle: true, - reset: expect.any(Function), - mutate: expect.any(Function), - mutateAsync: expect.any(Function) - }) + expect(result.current).toEqual(mutationIdleResult) expect(useMutationSpy).toHaveBeenCalledTimes(1) expect(useMutationSpy).toHaveBeenCalledWith({ @@ -111,16 +104,11 @@ describe('useSignerClientMutation', () => { await waitFor(() => expect(result.current.mutate).toEqual(expect.any(Function))) - result.current.mutate('test') + result.current.mutate(variables) await waitFor(() => expect(result.current.isSuccess).toEqual(true)) - expect(result.current.isIdle).toEqual(false) - expect(result.current.isPending).toEqual(false) - expect(result.current.isError).toEqual(false) - expect(result.current.isSuccess).toEqual(true) - expect(result.current.data).toEqual(safeMultisigTransaction) - expect(result.current.error).toEqual(null) + expect(result.current).toEqual(mutationSuccessResult) expect(mutationSafeClientFnMock).toHaveBeenCalledTimes(1) expect(mutationSafeClientFnMock).toHaveBeenCalledWith(signerClientMock, 'test') @@ -130,6 +118,14 @@ describe('useSignerClientMutation', () => { }) it('should return error data if signer client is not connected', async () => { + const error = new Error('Signer client is not available') + const mutationErrorResult = getCustomMutationResult({ + status: 'error', + mutateFnName: 'mutate', + error, + variables + }) + useSignerClientSpy.mockReturnValueOnce(undefined) const { result } = renderHookInQueryClientProvider(() => @@ -141,22 +137,25 @@ describe('useSignerClientMutation', () => { await waitFor(() => expect(result.current.mutate).toEqual(expect.any(Function))) - result.current.mutate('test') + result.current.mutate(variables) await waitFor(() => expect(result.current.isError).toEqual(true)) - expect(result.current.isIdle).toEqual(false) - expect(result.current.isPending).toEqual(false) - expect(result.current.isError).toEqual(true) - expect(result.current.isSuccess).toEqual(false) - expect(result.current.data).toEqual(undefined) - expect(result.current.error).toEqual(new Error('Signer client is not available')) + expect(result.current).toEqual(mutationErrorResult) expect(mutationSafeClientFnMock).toHaveBeenCalledTimes(0) expect(createAddOwnerTxMock).toHaveBeenCalledTimes(0) }) it('should return error data if the request fails', async () => { + const error = new Error('Error :(') + const mutationErrorResult = getCustomMutationResult({ + status: 'error', + mutateFnName: 'mutate', + error, + variables + }) + createAddOwnerTxMock.mockRejectedValueOnce(new Error('Error :(')) const { result } = renderHookInQueryClientProvider(() => @@ -168,24 +167,17 @@ describe('useSignerClientMutation', () => { await waitFor(() => expect(result.current.mutate).toEqual(expect.any(Function))) - result.current.mutate('test') + result.current.mutate(variables) await waitFor(() => expect(result.current.isError).toEqual(true)) - expect(result.current).toMatchObject({ - data: undefined, - status: 'error', - isError: true, - isSuccess: false, - isPending: false, - error: new Error('Error :(') - }) + expect(result.current).toEqual(mutationErrorResult) expect(mutationSafeClientFnMock).toHaveBeenCalledTimes(1) - expect(mutationSafeClientFnMock).toHaveBeenCalledWith(signerClientMock, 'test') + expect(mutationSafeClientFnMock).toHaveBeenCalledWith(signerClientMock, variables) expect(createAddOwnerTxMock).toHaveBeenCalledTimes(1) - expect(createAddOwnerTxMock).toHaveBeenCalledWith('test') + expect(createAddOwnerTxMock).toHaveBeenCalledWith(variables) }) }) @@ -200,15 +192,15 @@ describe('useSignerClientMutation', () => { await waitFor(() => expect(result.current.mutateAsync).toEqual(expect.any(Function))) - const sendResult = await result.current.mutateAsync('test') + const sendResult = await result.current.mutateAsync(variables) expect(sendResult).toEqual(safeMultisigTransaction) expect(mutationSafeClientFnMock).toHaveBeenCalledTimes(1) - expect(mutationSafeClientFnMock).toHaveBeenCalledWith(signerClientMock, 'test') + expect(mutationSafeClientFnMock).toHaveBeenCalledWith(signerClientMock, variables) expect(createAddOwnerTxMock).toHaveBeenCalledTimes(1) - expect(createAddOwnerTxMock).toHaveBeenCalledWith('test') + expect(createAddOwnerTxMock).toHaveBeenCalledWith(variables) }) it('should return error if signer client is not connected', async () => { @@ -223,7 +215,7 @@ describe('useSignerClientMutation', () => { await waitFor(() => expect(result.current.mutateAsync).toEqual(expect.any(Function))) - await expect(() => result.current.mutateAsync('test')).rejects.toThrow( + await expect(() => result.current.mutateAsync(variables)).rejects.toThrow( 'Signer client is not available' ) @@ -243,13 +235,13 @@ describe('useSignerClientMutation', () => { await waitFor(() => expect(result.current.mutateAsync).toEqual(expect.any(Function))) - await expect(() => result.current.mutateAsync('test')).rejects.toThrow('Error :(') + await expect(() => result.current.mutateAsync(variables)).rejects.toThrow('Error :(') expect(mutationSafeClientFnMock).toHaveBeenCalledTimes(1) - expect(mutationSafeClientFnMock).toHaveBeenCalledWith(signerClientMock, 'test') + expect(mutationSafeClientFnMock).toHaveBeenCalledWith(signerClientMock, variables) expect(createAddOwnerTxMock).toHaveBeenCalledTimes(1) - expect(createAddOwnerTxMock).toHaveBeenCalledWith('test') + expect(createAddOwnerTxMock).toHaveBeenCalledWith(variables) }) }) }) diff --git a/src/hooks/useUpdateThreshold.test.ts b/src/hooks/useUpdateThreshold.test.ts index 5d45d66..f89afc0 100644 --- a/src/hooks/useUpdateThreshold.test.ts +++ b/src/hooks/useUpdateThreshold.test.ts @@ -5,45 +5,37 @@ import { useUpdateThreshold } from '@/hooks/useUpdateThreshold.js' import * as useSendTransaction from '@/hooks/useSendTransaction.js' import * as useSignerClientMutation from '@/hooks/useSignerClientMutation.js' import { ethereumTxHash, safeMultisigTransaction, signerPrivateKeys } from '@test/fixtures/index.js' -import { MutationKey } from '@/constants.js' import { configPredictedSafe } from '@test/config.js' +import { getCustomMutationResult } from '@test/fixtures/mutationResult.js' import { renderHookInQueryClientProvider } from '@test/utils.js' +import { MutationKey } from '@/constants.js' describe('useUpdateThreshold', () => { const threshold = 2 + const changeThresholdTxMock = safeMultisigTransaction + const sendTransactionResultMock = ethereumTxHash + + const mutateFnName = 'updateThreshold' + const variables = { threshold } + const mutationIdleResult = getCustomMutationResult({ status: 'idle', mutateFnName }) + const mutationSuccessResult = getCustomMutationResult({ + status: 'success', + mutateFnName, + data: sendTransactionResultMock, + variables + }) const useSendTransactionSpy = jest.spyOn(useSendTransaction, 'useSendTransaction') const useSignerClientMutationSpy = jest.spyOn(useSignerClientMutation, 'useSignerClientMutation') - const updateThresholdResultMock = safeMultisigTransaction - const createChangeThresholdTxMock = jest.fn().mockResolvedValue(updateThresholdResultMock) + const createChangeThresholdTxMock = jest.fn().mockResolvedValue(changeThresholdTxMock) - const sendTransactionResultMock = ethereumTxHash const sendTransactionAsyncMock = jest.fn().mockResolvedValue(sendTransactionResultMock) const signerClientMock = { protocolKit: { createChangeThresholdTx: createChangeThresholdTxMock } } as unknown as SafeClient - const mutationIdleResult = { - updateThreshold: expect.any(Function), - updateThresholdAsync: expect.any(Function), - isIdle: true, - isPaused: false, - isPending: false, - isSuccess: false, - reset: expect.any(Function), - status: 'idle', - submittedAt: 0, - variables: undefined, - context: undefined, - data: undefined, - error: null, - failureCount: 0, - failureReason: null, - isError: false - } - beforeEach(() => { useSignerClientMutationSpy.mockImplementation( ({ @@ -130,43 +122,30 @@ describe('useUpdateThreshold', () => { await waitFor(() => expect(result.current.isSuccess).toBeTruthy()) - expect(result.current.data).toEqual(sendTransactionResultMock) + expect(result.current).toEqual(mutationSuccessResult) expect(createChangeThresholdTxMock).toHaveBeenCalledTimes(1) expect(createChangeThresholdTxMock).toHaveBeenCalledWith(threshold) expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(1) expect(sendTransactionAsyncMock).toHaveBeenCalledWith({ - transactions: [safeMultisigTransaction] + transactions: [changeThresholdTxMock] }) } ) describe('should return error data', () => { - const getMutationErrorResult = (error: Error) => ({ - context: undefined, - isIdle: false, - isPaused: false, - isPending: false, - isSuccess: false, - status: 'error', - data: undefined, - error, - failureCount: 1, - failureReason: error, - isError: true, - reset: expect.any(Function), - submittedAt: expect.any(Number), - variables: { threshold }, - updateThreshold: expect.any(Function), - updateThresholdAsync: expect.any(Function) - }) - it.each<'updateThreshold' | 'updateThresholdAsync'>([ 'updateThreshold', 'updateThresholdAsync' ])('if creating a transaction for updating the thresholds fails for `%s`', async (fnName) => { const error = new Error('Error creating transaction') + const mutationErrorResult = getCustomMutationResult({ + status: 'error', + mutateFnName, + error, + variables + }) createChangeThresholdTxMock.mockRejectedValueOnce(error) @@ -180,7 +159,7 @@ describe('useUpdateThreshold', () => { await waitFor(() => expect(result.current.isError).toBeTruthy()) - expect(result.current).toEqual(getMutationErrorResult(error)) + expect(result.current).toEqual(mutationErrorResult) expect(createChangeThresholdTxMock).toHaveBeenCalledTimes(1) expect(createChangeThresholdTxMock).toHaveBeenCalledWith(threshold) @@ -190,6 +169,12 @@ describe('useUpdateThreshold', () => { it('if sending the threshold update transaction fails', async () => { const error = new Error('Error sending transaction') + const mutationErrorResult = getCustomMutationResult({ + status: 'error', + mutateFnName, + error, + variables + }) sendTransactionAsyncMock.mockRejectedValueOnce(error) @@ -199,14 +184,14 @@ describe('useUpdateThreshold', () => { await waitFor(() => expect(result.current.isError).toBeTruthy()) - expect(result.current).toEqual(getMutationErrorResult(error)) + expect(result.current).toEqual(mutationErrorResult) expect(createChangeThresholdTxMock).toHaveBeenCalledTimes(1) expect(createChangeThresholdTxMock).toHaveBeenCalledWith(threshold) expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(1) expect(sendTransactionAsyncMock).toHaveBeenCalledWith({ - transactions: [safeMultisigTransaction] + transactions: [changeThresholdTxMock] }) }) }) From 9f2559a58d0b21cd465e19df1d4e198c9e2555f7 Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Tue, 1 Oct 2024 17:41:08 +0200 Subject: [PATCH 19/29] Add unit tests for `useRemoveOwner` hook --- .../useUpdateOwners/useRemoveOwner.test.ts | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 src/hooks/useUpdateOwners/useRemoveOwner.test.ts diff --git a/src/hooks/useUpdateOwners/useRemoveOwner.test.ts b/src/hooks/useUpdateOwners/useRemoveOwner.test.ts new file mode 100644 index 0000000..6f0306d --- /dev/null +++ b/src/hooks/useUpdateOwners/useRemoveOwner.test.ts @@ -0,0 +1,210 @@ +import { useMutation } from '@tanstack/react-query' +import { waitFor } from '@testing-library/dom' +import { SafeClient } from '@safe-global/sdk-starter-kit' +import * as useSendTransaction from '@/hooks/useSendTransaction.js' +import * as useSignerClientMutation from '@/hooks/useSignerClientMutation.js' +import { useRemoveOwner } from '@/hooks/useUpdateOwners/useRemoveOwner.js' +import { + accounts, + ethereumTxHash, + safeMultisigTransaction, + signerPrivateKeys +} from '@test/fixtures/index.js' +import { configPredictedSafe } from '@test/config.js' +import { getCustomMutationResult } from '@test/fixtures/mutationResult.js' +import { renderHookInQueryClientProvider } from '@test/utils.js' +import { MutationKey } from '@/constants.js' + +describe('useRemoveOwner', () => { + const ownerAddress = accounts[1] + + const mutateFnName = 'removeOwner' + const variables = { ownerAddress } + const mutationIdleResult = getCustomMutationResult({ status: 'idle', mutateFnName }) + const mutationSuccessResult = getCustomMutationResult({ + status: 'success', + mutateFnName, + data: ethereumTxHash, + variables + }) + + const useSendTransactionSpy = jest.spyOn(useSendTransaction, 'useSendTransaction') + const useSignerClientMutationSpy = jest.spyOn(useSignerClientMutation, 'useSignerClientMutation') + + const createRemoveOwnerTxResultMock = safeMultisigTransaction + const createRemoveOwnerTxMock = jest.fn().mockResolvedValue(createRemoveOwnerTxResultMock) + + const sendTransactionResultMock = ethereumTxHash + const sendTransactionAsyncMock = jest.fn().mockResolvedValue(sendTransactionResultMock) + + const signerClientMock = { + protocolKit: { createRemoveOwnerTx: createRemoveOwnerTxMock } + } as unknown as SafeClient + + beforeEach(() => { + useSignerClientMutationSpy.mockImplementation( + ({ + mutationSafeClientFn, + mutationKey + }: useSignerClientMutation.UseSignerClientMutationParams< + SafeClientResult, + RemoveOwnerVariables + >) => + useMutation({ + mutationKey, + mutationFn: (params: RemoveOwnerVariables) => + mutationSafeClientFn(signerClientMock, params) + }) + ) + + useSendTransactionSpy.mockReturnValue({ + sendTransactionAsync: sendTransactionAsyncMock + } as unknown as useSendTransaction.UseSendTransactionReturnType) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should return result of `useSignerClientMutation` call with `removeOwner` + `removeOwnerAsync` functions', () => { + const { result } = renderHookInQueryClientProvider(() => useRemoveOwner()) + + expect(useSendTransactionSpy).toHaveBeenCalledTimes(1) + expect(useSendTransactionSpy).toHaveBeenCalledWith({ config: undefined }) + + expect(useSignerClientMutationSpy).toHaveBeenCalledTimes(1) + expect(useSignerClientMutationSpy).toHaveBeenCalledWith({ + mutationSafeClientFn: expect.any(Function), + mutationKey: [MutationKey.RemoveOwner] + }) + + expect(result.current).toEqual(mutationIdleResult) + + expect(useSignerClientMutationSpy).toHaveBeenCalledTimes(1) + expect(useSignerClientMutationSpy).toHaveBeenCalledWith({ + mutationKey: [MutationKey.RemoveOwner], + mutationSafeClientFn: expect.any(Function) + }) + + expect(createRemoveOwnerTxMock).toHaveBeenCalledTimes(0) + expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(0) + }) + + it('should accept a config to override the one from the SafeProvider', async () => { + const config = { ...configPredictedSafe, signer: signerPrivateKeys[0] } + + const { result } = renderHookInQueryClientProvider(() => useRemoveOwner({ config })) + + expect(useSendTransactionSpy).toHaveBeenCalledTimes(1) + expect(useSendTransactionSpy).toHaveBeenCalledWith({ config }) + + expect(useSignerClientMutationSpy).toHaveBeenCalledTimes(1) + expect(useSignerClientMutationSpy).toHaveBeenCalledWith({ + config, + mutationSafeClientFn: expect.any(Function), + mutationKey: [MutationKey.RemoveOwner] + }) + + expect(result.current).toEqual(mutationIdleResult) + + expect(createRemoveOwnerTxMock).toHaveBeenCalledTimes(0) + expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(0) + }) + + it.each<'removeOwner' | 'removeOwnerAsync'>(['removeOwner', 'removeOwnerAsync'])( + 'calling `%s` should create and send a transaction to remove an owner', + async (fnName) => { + const { result } = renderHookInQueryClientProvider(() => useRemoveOwner()) + + expect(createRemoveOwnerTxMock).toHaveBeenCalledTimes(0) + expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(0) + + const removeOwnerResult = await result.current[fnName](variables) + + if (fnName === 'removeOwnerAsync') { + expect(removeOwnerResult).toEqual(sendTransactionResultMock) + } + + await waitFor(() => expect(result.current.isSuccess).toBeTruthy()) + + expect(result.current).toEqual(mutationSuccessResult) + + expect(createRemoveOwnerTxMock).toHaveBeenCalledTimes(1) + expect(createRemoveOwnerTxMock).toHaveBeenCalledWith(variables) + + expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(1) + expect(sendTransactionAsyncMock).toHaveBeenCalledWith({ + transactions: [createRemoveOwnerTxResultMock] + }) + } + ) + + describe('should return error data', () => { + it.each<'removeOwner' | 'removeOwnerAsync'>(['removeOwner', 'removeOwnerAsync'])( + 'if creating a transaction for removing an owner fails for `%s`', + async (fnName) => { + const error = new Error('Error creating remove owner transaction') + const mutationErrorResult = getCustomMutationResult({ + status: 'error', + mutateFnName, + error, + variables + }) + + createRemoveOwnerTxMock.mockRejectedValueOnce(error) + + const { result } = renderHookInQueryClientProvider(() => useRemoveOwner()) + + if (fnName === 'removeOwnerAsync') { + await expect(result.current.removeOwnerAsync(variables)).rejects.toEqual(error) + } else { + result.current.removeOwner(variables) + } + + await waitFor(() => expect(result.current.isError).toBeTruthy()) + + expect(result.current).toEqual(mutationErrorResult) + + expect(createRemoveOwnerTxMock).toHaveBeenCalledTimes(1) + expect(createRemoveOwnerTxMock).toHaveBeenCalledWith(variables) + + expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(0) + } + ) + + it.each<'removeOwner' | 'removeOwnerAsync'>(['removeOwner', 'removeOwnerAsync'])( + 'if sending a transaction for removing an owner fails for `%s`', + async (fnName) => { + const error = new Error('Error sending remove owner transaction') + const mutationErrorResult = getCustomMutationResult({ + status: 'error', + mutateFnName, + error, + variables + }) + + sendTransactionAsyncMock.mockRejectedValueOnce(error) + + const { result } = renderHookInQueryClientProvider(() => useRemoveOwner()) + + if (fnName === 'removeOwnerAsync') { + await expect(result.current.removeOwnerAsync(variables)).rejects.toEqual(error) + } else { + result.current.removeOwner(variables) + } + + await waitFor(() => expect(result.current.isError).toBeTruthy()) + + expect(result.current).toEqual(mutationErrorResult) + + expect(createRemoveOwnerTxMock).toHaveBeenCalledTimes(1) + expect(createRemoveOwnerTxMock).toHaveBeenCalledWith(variables) + + expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(1) + expect(sendTransactionAsyncMock).toHaveBeenCalledWith({ + transactions: [createRemoveOwnerTxResultMock] + }) + } + ) + }) +}) From 0aad1732d9e5cb4b1858c0c2866dac283f1004b6 Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Tue, 1 Oct 2024 17:41:20 +0200 Subject: [PATCH 20/29] Add unit tests for `useSwapOwner` hook --- .../useUpdateOwners/useSwapOwner.test.ts | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 src/hooks/useUpdateOwners/useSwapOwner.test.ts diff --git a/src/hooks/useUpdateOwners/useSwapOwner.test.ts b/src/hooks/useUpdateOwners/useSwapOwner.test.ts new file mode 100644 index 0000000..eb63153 --- /dev/null +++ b/src/hooks/useUpdateOwners/useSwapOwner.test.ts @@ -0,0 +1,207 @@ +import { useMutation } from '@tanstack/react-query' +import { waitFor } from '@testing-library/dom' +import { SafeClient } from '@safe-global/sdk-starter-kit' +import * as useSendTransaction from '@/hooks/useSendTransaction.js' +import * as useSignerClientMutation from '@/hooks/useSignerClientMutation.js' +import { useSwapOwner } from '@/hooks/useUpdateOwners/useSwapOwner.js' +import { + accounts, + ethereumTxHash, + safeMultisigTransaction, + signerPrivateKeys +} from '@test/fixtures/index.js' +import { configPredictedSafe } from '@test/config.js' +import { getCustomMutationResult } from '@test/fixtures/mutationResult.js' +import { renderHookInQueryClientProvider } from '@test/utils.js' +import { MutationKey } from '@/constants.js' + +describe('useSwapOwner', () => { + const mutateFnName = 'swapOwner' + const variables = { oldOwnerAddress: accounts[0], newOwnerAddress: accounts[1] } + const mutationIdleResult = getCustomMutationResult({ status: 'idle', mutateFnName }) + const mutationSuccessResult = getCustomMutationResult({ + status: 'success', + mutateFnName, + data: ethereumTxHash, + variables + }) + + const useSendTransactionSpy = jest.spyOn(useSendTransaction, 'useSendTransaction') + const useSignerClientMutationSpy = jest.spyOn(useSignerClientMutation, 'useSignerClientMutation') + + const createSwapOwnerTxResultMock = safeMultisigTransaction + const createSwapOwnerTxMock = jest.fn().mockResolvedValue(createSwapOwnerTxResultMock) + + const sendTransactionResultMock = ethereumTxHash + const sendTransactionAsyncMock = jest.fn().mockResolvedValue(sendTransactionResultMock) + + const signerClientMock = { + protocolKit: { createSwapOwnerTx: createSwapOwnerTxMock } + } as unknown as SafeClient + + beforeEach(() => { + useSignerClientMutationSpy.mockImplementation( + ({ + mutationSafeClientFn, + mutationKey + }: useSignerClientMutation.UseSignerClientMutationParams< + SafeClientResult, + SwapOwnerVariables + >) => + useMutation({ + mutationKey, + mutationFn: (params: SwapOwnerVariables) => mutationSafeClientFn(signerClientMock, params) + }) + ) + + useSendTransactionSpy.mockReturnValue({ + sendTransactionAsync: sendTransactionAsyncMock + } as unknown as useSendTransaction.UseSendTransactionReturnType) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should return result of `useSignerClientMutation` call with `swapOwner` + `swapOwnerAsync` functions', () => { + const { result } = renderHookInQueryClientProvider(() => useSwapOwner()) + + expect(useSendTransactionSpy).toHaveBeenCalledTimes(1) + expect(useSendTransactionSpy).toHaveBeenCalledWith({ config: undefined }) + + expect(useSignerClientMutationSpy).toHaveBeenCalledTimes(1) + expect(useSignerClientMutationSpy).toHaveBeenCalledWith({ + mutationSafeClientFn: expect.any(Function), + mutationKey: [MutationKey.SwapOwner] + }) + + expect(result.current).toEqual(mutationIdleResult) + + expect(useSignerClientMutationSpy).toHaveBeenCalledTimes(1) + expect(useSignerClientMutationSpy).toHaveBeenCalledWith({ + mutationKey: [MutationKey.SwapOwner], + mutationSafeClientFn: expect.any(Function) + }) + + expect(createSwapOwnerTxMock).toHaveBeenCalledTimes(0) + expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(0) + }) + + it('should accept a config to override the one from the SafeProvider', async () => { + const config = { ...configPredictedSafe, signer: signerPrivateKeys[0] } + + const { result } = renderHookInQueryClientProvider(() => useSwapOwner({ config })) + + expect(useSendTransactionSpy).toHaveBeenCalledTimes(1) + expect(useSendTransactionSpy).toHaveBeenCalledWith({ config }) + + expect(useSignerClientMutationSpy).toHaveBeenCalledTimes(1) + expect(useSignerClientMutationSpy).toHaveBeenCalledWith({ + config, + mutationSafeClientFn: expect.any(Function), + mutationKey: [MutationKey.SwapOwner] + }) + + expect(result.current).toEqual(mutationIdleResult) + + expect(createSwapOwnerTxMock).toHaveBeenCalledTimes(0) + expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(0) + }) + + it.each<'swapOwner' | 'swapOwnerAsync'>(['swapOwner', 'swapOwnerAsync'])( + 'calling `%s` should create and send a transaction to swap an owner', + async (fnName) => { + const { result } = renderHookInQueryClientProvider(() => useSwapOwner()) + + expect(createSwapOwnerTxMock).toHaveBeenCalledTimes(0) + expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(0) + + const swapOwnerResult = await result.current[fnName](variables) + + if (fnName === 'swapOwnerAsync') { + expect(swapOwnerResult).toEqual(sendTransactionResultMock) + } + + await waitFor(() => expect(result.current.isSuccess).toBeTruthy()) + + expect(result.current).toEqual(mutationSuccessResult) + + expect(createSwapOwnerTxMock).toHaveBeenCalledTimes(1) + expect(createSwapOwnerTxMock).toHaveBeenCalledWith(variables) + + expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(1) + expect(sendTransactionAsyncMock).toHaveBeenCalledWith({ + transactions: [createSwapOwnerTxResultMock] + }) + } + ) + + describe('should return error data', () => { + it.each<'swapOwner' | 'swapOwnerAsync'>(['swapOwner', 'swapOwnerAsync'])( + 'if creating a transaction for swapping an owner fails for `%s`', + async (fnName) => { + const error = new Error('Error creating swapt owner transaction') + const mutationErrorResult = getCustomMutationResult({ + status: 'error', + mutateFnName, + error, + variables + }) + + createSwapOwnerTxMock.mockRejectedValueOnce(error) + + const { result } = renderHookInQueryClientProvider(() => useSwapOwner()) + + if (fnName === 'swapOwnerAsync') { + await expect(result.current.swapOwnerAsync(variables)).rejects.toEqual(error) + } else { + result.current.swapOwner(variables) + } + + await waitFor(() => expect(result.current.isError).toBeTruthy()) + + expect(result.current).toEqual(mutationErrorResult) + + expect(createSwapOwnerTxMock).toHaveBeenCalledTimes(1) + expect(createSwapOwnerTxMock).toHaveBeenCalledWith(variables) + + expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(0) + } + ) + + it.each<'swapOwner' | 'swapOwnerAsync'>(['swapOwner', 'swapOwnerAsync'])( + 'if sending a transaction for swapping an owner fails for `%s`', + async (fnName) => { + const error = new Error('Error sending swap owner transaction') + const mutationErrorResult = getCustomMutationResult({ + status: 'error', + mutateFnName, + error, + variables + }) + + sendTransactionAsyncMock.mockRejectedValueOnce(error) + + const { result } = renderHookInQueryClientProvider(() => useSwapOwner()) + + if (fnName === 'swapOwnerAsync') { + await expect(result.current.swapOwnerAsync(variables)).rejects.toEqual(error) + } else { + result.current.swapOwner(variables) + } + + await waitFor(() => expect(result.current.isError).toBeTruthy()) + + expect(result.current).toEqual(mutationErrorResult) + + expect(createSwapOwnerTxMock).toHaveBeenCalledTimes(1) + expect(createSwapOwnerTxMock).toHaveBeenCalledWith(variables) + + expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(1) + expect(sendTransactionAsyncMock).toHaveBeenCalledWith({ + transactions: [createSwapOwnerTxResultMock] + }) + } + ) + }) +}) From e2da97ac4601b6b3168c7e2ad189928ba445ca6c Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Tue, 1 Oct 2024 17:41:36 +0200 Subject: [PATCH 21/29] Fix tests --- src/hooks/useUpdateOwners/useAddOwner.test.ts | 49 +++++++++++-------- src/hooks/useUpdateThreshold.test.ts | 11 ++++- 2 files changed, 37 insertions(+), 23 deletions(-) diff --git a/src/hooks/useUpdateOwners/useAddOwner.test.ts b/src/hooks/useUpdateOwners/useAddOwner.test.ts index 01040a1..b0fb537 100644 --- a/src/hooks/useUpdateOwners/useAddOwner.test.ts +++ b/src/hooks/useUpdateOwners/useAddOwner.test.ts @@ -142,7 +142,7 @@ describe('useAddOwner', () => { it.each<'addOwner' | 'addOwnerAsync'>(['addOwner', 'addOwnerAsync'])( 'if creating a transaction for adding an owner fails for `%s`', async (fnName) => { - const error = new Error('Error creating transaction') + const error = new Error('Error creating add owner transaction') const mutationErrorResult = getCustomMutationResult({ status: 'error', mutateFnName, @@ -171,32 +171,39 @@ describe('useAddOwner', () => { } ) - it('if sending the threshold add owner transaction fails', async () => { - const error = new Error('Error sending transaction') - const mutationErrorResult = getCustomMutationResult({ - status: 'error', - mutateFnName, - error, - variables - }) + it.each<'addOwner' | 'addOwnerAsync'>(['addOwner', 'addOwnerAsync'])( + 'if sending a transaction for adding an owner fails for `%s`', + async (fnName) => { + const error = new Error('Error sending add owner transaction') + const mutationErrorResult = getCustomMutationResult({ + status: 'error', + mutateFnName, + error, + variables + }) - sendTransactionAsyncMock.mockRejectedValueOnce(error) + sendTransactionAsyncMock.mockRejectedValueOnce(error) - const { result } = renderHookInQueryClientProvider(() => useAddOwner()) + const { result } = renderHookInQueryClientProvider(() => useAddOwner()) - result.current.addOwner(variables) + if (fnName === 'addOwnerAsync') { + await expect(result.current.addOwnerAsync(variables)).rejects.toEqual(error) + } else { + result.current.addOwner(variables) + } - await waitFor(() => expect(result.current.isError).toBeTruthy()) + await waitFor(() => expect(result.current.isError).toBeTruthy()) - expect(result.current).toEqual(mutationErrorResult) + expect(result.current).toEqual(mutationErrorResult) - expect(createAddOwnerTxMock).toHaveBeenCalledTimes(1) - expect(createAddOwnerTxMock).toHaveBeenCalledWith(variables) + expect(createAddOwnerTxMock).toHaveBeenCalledTimes(1) + expect(createAddOwnerTxMock).toHaveBeenCalledWith(variables) - expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(1) - expect(sendTransactionAsyncMock).toHaveBeenCalledWith({ - transactions: [createAddOwnerTxResultMock] - }) - }) + expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(1) + expect(sendTransactionAsyncMock).toHaveBeenCalledWith({ + transactions: [createAddOwnerTxResultMock] + }) + } + ) }) }) diff --git a/src/hooks/useUpdateThreshold.test.ts b/src/hooks/useUpdateThreshold.test.ts index f89afc0..63dd5f3 100644 --- a/src/hooks/useUpdateThreshold.test.ts +++ b/src/hooks/useUpdateThreshold.test.ts @@ -167,7 +167,10 @@ describe('useUpdateThreshold', () => { expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(0) }) - it('if sending the threshold update transaction fails', async () => { + it.each<'updateThreshold' | 'updateThresholdAsync'>([ + 'updateThreshold', + 'updateThresholdAsync' + ])('if sending the threshold update transaction fails for `%s`', async (fnName) => { const error = new Error('Error sending transaction') const mutationErrorResult = getCustomMutationResult({ status: 'error', @@ -180,7 +183,11 @@ describe('useUpdateThreshold', () => { const { result } = renderHookInQueryClientProvider(() => useUpdateThreshold()) - result.current.updateThreshold({ threshold }) + if (fnName === 'updateThresholdAsync') { + await expect(result.current.updateThresholdAsync({ threshold })).rejects.toEqual(error) + } else { + result.current.updateThreshold({ threshold }) + } await waitFor(() => expect(result.current.isError).toBeTruthy()) From 0e3ccf500eecd091bec56e0e2eabcccfc7a39f19 Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Tue, 1 Oct 2024 18:15:39 +0200 Subject: [PATCH 22/29] Add unit tests for `useUpdateOwners` hook --- src/hooks/useUpdateOwners/index.test.ts | 59 +++++++++++++++++++++++++ test/fixtures/mutationResult.ts | 2 +- 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useUpdateOwners/index.test.ts diff --git a/src/hooks/useUpdateOwners/index.test.ts b/src/hooks/useUpdateOwners/index.test.ts new file mode 100644 index 0000000..3ee1427 --- /dev/null +++ b/src/hooks/useUpdateOwners/index.test.ts @@ -0,0 +1,59 @@ +import * as useAddOwner from '@/hooks/useUpdateOwners/useAddOwner.js' +import * as useRemoveOwner from '@/hooks/useUpdateOwners/useRemoveOwner.js' +import * as useSwapOwner from '@/hooks/useUpdateOwners/useSwapOwner.js' +import { configExistingSafe } from '@test/config.js' +import { signerPrivateKeys } from '@test/fixtures/index.js' +import { renderHookInMockedSafeProvider } from '@test/utils.js' +import { getCustomMutationResult } from '@test/fixtures/mutationResult.js' +import { useUpdateOwners } from './index.js' + +describe('useUpdateOwners', () => { + const useAddOwnerSpy = jest.spyOn(useAddOwner, 'useAddOwner') + const useRemoveOwnerSpy = jest.spyOn(useRemoveOwner, 'useRemoveOwner') + const useSwapOwnerSpy = jest.spyOn(useSwapOwner, 'useSwapOwner') + + const useAddOwnerResultMock = getCustomMutationResult({ + status: 'idle', + mutateFnName: 'addOwner' + }) as unknown as useAddOwner.UseAddOwnerReturnType + const useRemoveOwnerResultMock = getCustomMutationResult({ + status: 'idle', + mutateFnName: 'removeOwner' + }) as unknown as useRemoveOwner.UseRemoveOwnerReturnType + const useSwapOwnerResultMock = getCustomMutationResult({ + status: 'idle', + mutateFnName: 'swapOwner' + }) as unknown as useSwapOwner.UseSwapOwnerReturnType + + beforeEach(() => { + useAddOwnerSpy.mockReturnValue(useAddOwnerResultMock) + useRemoveOwnerSpy.mockReturnValue(useRemoveOwnerResultMock) + useSwapOwnerSpy.mockReturnValue(useSwapOwnerResultMock) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it.each([ + ['without config parameter', { config: undefined }], + ['with config parameter', { config: { ...configExistingSafe, signer: signerPrivateKeys[0] } }] + ])( + 'should return object wrapping individual hooks to add, remove or swap an owner when being called %s', + async (_label, params) => { + const { result } = renderHookInMockedSafeProvider(() => useUpdateOwners(params), { + config: { ...configExistingSafe, signer: signerPrivateKeys[0] } + }) + + expect(result.current).toMatchObject({ + add: useAddOwnerResultMock, + remove: useRemoveOwnerResultMock, + swap: useSwapOwnerResultMock + }) + + expect(useAddOwnerSpy).toHaveBeenCalledWith(params) + expect(useRemoveOwnerSpy).toHaveBeenCalledWith(params) + expect(useSwapOwnerSpy).toHaveBeenCalledWith(params) + } + ) +}) diff --git a/test/fixtures/mutationResult.ts b/test/fixtures/mutationResult.ts index 3769a39..ce5fde8 100644 --- a/test/fixtures/mutationResult.ts +++ b/test/fixtures/mutationResult.ts @@ -84,7 +84,7 @@ export function getCustomMutationResult< [`${mutateFnName}Async`]: mutate, ...(status === 'success' && data ? { data, variables } : {}), ...(status === 'error' && error ? { error, failureReason: error, variables } : {}) - } as TResult & { + } as Omit & { [k in TMutateFnName]: typeof mutate } & { [key in `${TMutateFnName}Async`]: typeof mutate From 899658d6737bc959f65dfa66052865eddf98394d Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Wed, 2 Oct 2024 13:28:22 +0200 Subject: [PATCH 23/29] refactor: Rename `getCustomMutationResult` to `createCustomMutationResult` for consistency --- src/hooks/useConfirmTransaction.test.ts | 8 ++++---- src/hooks/useSendTransaction.test.ts | 8 ++++---- src/hooks/useSignerClientMutation.test.ts | 10 +++++----- src/hooks/useUpdateOwners/index.test.ts | 8 ++++---- src/hooks/useUpdateOwners/useAddOwner.test.ts | 10 +++++----- src/hooks/useUpdateOwners/useRemoveOwner.test.ts | 10 +++++----- src/hooks/useUpdateOwners/useSwapOwner.test.ts | 10 +++++----- src/hooks/useUpdateThreshold.test.ts | 10 +++++----- test/fixtures/mutationResult.ts | 2 +- 9 files changed, 38 insertions(+), 38 deletions(-) diff --git a/src/hooks/useConfirmTransaction.test.ts b/src/hooks/useConfirmTransaction.test.ts index d25574c..47ebdf1 100644 --- a/src/hooks/useConfirmTransaction.test.ts +++ b/src/hooks/useConfirmTransaction.test.ts @@ -7,7 +7,7 @@ import * as useWaitForTransaction from '@/hooks/useWaitForTransaction.js' import * as useSignerClientMutation from '@/hooks/useSignerClientMutation.js' import { configExistingSafe } from '@test/config.js' import { ethereumTxHash, safeAddress, safeTxHash, signerPrivateKeys } from '@test/fixtures/index.js' -import { getCustomMutationResult } from '@test/fixtures/mutationResult.js' +import { createCustomMutationResult } from '@test/fixtures/mutationResult.js' import { renderHookInQueryClientProvider } from '@test/utils.js' import { MutationKey, QueryKey } from '@/constants.js' import { queryClient } from '@/queryClient.js' @@ -28,11 +28,11 @@ describe('useConfirmTransaction', () => { const mutateFnName = 'confirmTransaction' const variables = { safeTxHash } - const mutationIdleResult = getCustomMutationResult({ + const mutationIdleResult = createCustomMutationResult({ status: 'idle', mutateFnName }) - const mutationSuccessResult = getCustomMutationResult({ + const mutationSuccessResult = createCustomMutationResult({ status: 'success', mutateFnName, data: confirmResponseMock, @@ -218,7 +218,7 @@ describe('useConfirmTransaction', () => { 'confirmTransactionAsync' ])('calling `%s` should return error data if the `confirm` request fails', async (fnName) => { const error = new Error('Confirm transaction failed :(') - const mutationErrorResult = getCustomMutationResult({ + const mutationErrorResult = createCustomMutationResult({ status: 'error', mutateFnName, error, diff --git a/src/hooks/useSendTransaction.test.ts b/src/hooks/useSendTransaction.test.ts index cb7c843..b6488b3 100644 --- a/src/hooks/useSendTransaction.test.ts +++ b/src/hooks/useSendTransaction.test.ts @@ -6,7 +6,7 @@ import * as useWaitForTransaction from '@/hooks/useWaitForTransaction.js' import * as useSignerClientMutation from '@/hooks/useSignerClientMutation.js' import { configExistingSafe } from '@test/config.js' import { ethereumTxHash, safeAddress, safeTxHash, signerPrivateKeys } from '@test/fixtures/index.js' -import { getCustomMutationResult } from '@test/fixtures/mutationResult.js' +import { createCustomMutationResult } from '@test/fixtures/mutationResult.js' import { renderHookInQueryClientProvider } from '@test/utils.js' import { MutationKey, QueryKey } from '@/constants.js' import { queryClient } from '@/queryClient.js' @@ -31,11 +31,11 @@ describe('useSendTransaction', () => { const mutateFnName = 'sendTransaction' const variables = { transactions: [transactionMock] } - const mutationIdleResult = getCustomMutationResult({ + const mutationIdleResult = createCustomMutationResult({ status: 'idle', mutateFnName }) - const mutationSuccessResult = getCustomMutationResult({ + const mutationSuccessResult = createCustomMutationResult({ status: 'success', mutateFnName, data: sendResponseMock, @@ -215,7 +215,7 @@ describe('useSendTransaction', () => { 'calling `%s` should return error data if the `send` request fails', async (fnName) => { const error = new Error('Send transaction failed :(') - const mutationErrorResult = getCustomMutationResult({ + const mutationErrorResult = createCustomMutationResult({ status: 'error', mutateFnName, error, diff --git a/src/hooks/useSignerClientMutation.test.ts b/src/hooks/useSignerClientMutation.test.ts index c75b646..d3702eb 100644 --- a/src/hooks/useSignerClientMutation.test.ts +++ b/src/hooks/useSignerClientMutation.test.ts @@ -6,7 +6,7 @@ import * as useSignerClient from '@/hooks/useSignerClient.js' import * as useConfig from '@/hooks/useConfig.js' import { configExistingSafe } from '@test/config.js' import { safeMultisigTransaction, signerPrivateKeys } from '@test/fixtures/index.js' -import { getCustomMutationResult } from '@test/fixtures/mutationResult.js' +import { createCustomMutationResult } from '@test/fixtures/mutationResult.js' import { renderHookInQueryClientProvider } from '@test/utils.js' // This is necessary to set a spy on the `useMutation` function without getting the following error: @@ -25,8 +25,8 @@ describe('useSignerClientMutation', () => { const createAddOwnerTxMock = jest.fn().mockResolvedValue(safeMultisigTransaction) const variables = 'test' - const mutationIdleResult = getCustomMutationResult({ status: 'idle', mutateFnName: 'mutate' }) - const mutationSuccessResult = getCustomMutationResult({ + const mutationIdleResult = createCustomMutationResult({ status: 'idle', mutateFnName: 'mutate' }) + const mutationSuccessResult = createCustomMutationResult({ status: 'success', mutateFnName: 'mutate', data: safeMultisigTransaction, @@ -119,7 +119,7 @@ describe('useSignerClientMutation', () => { it('should return error data if signer client is not connected', async () => { const error = new Error('Signer client is not available') - const mutationErrorResult = getCustomMutationResult({ + const mutationErrorResult = createCustomMutationResult({ status: 'error', mutateFnName: 'mutate', error, @@ -149,7 +149,7 @@ describe('useSignerClientMutation', () => { it('should return error data if the request fails', async () => { const error = new Error('Error :(') - const mutationErrorResult = getCustomMutationResult({ + const mutationErrorResult = createCustomMutationResult({ status: 'error', mutateFnName: 'mutate', error, diff --git a/src/hooks/useUpdateOwners/index.test.ts b/src/hooks/useUpdateOwners/index.test.ts index 3ee1427..e692b60 100644 --- a/src/hooks/useUpdateOwners/index.test.ts +++ b/src/hooks/useUpdateOwners/index.test.ts @@ -4,7 +4,7 @@ import * as useSwapOwner from '@/hooks/useUpdateOwners/useSwapOwner.js' import { configExistingSafe } from '@test/config.js' import { signerPrivateKeys } from '@test/fixtures/index.js' import { renderHookInMockedSafeProvider } from '@test/utils.js' -import { getCustomMutationResult } from '@test/fixtures/mutationResult.js' +import { createCustomMutationResult } from '@test/fixtures/mutationResult.js' import { useUpdateOwners } from './index.js' describe('useUpdateOwners', () => { @@ -12,15 +12,15 @@ describe('useUpdateOwners', () => { const useRemoveOwnerSpy = jest.spyOn(useRemoveOwner, 'useRemoveOwner') const useSwapOwnerSpy = jest.spyOn(useSwapOwner, 'useSwapOwner') - const useAddOwnerResultMock = getCustomMutationResult({ + const useAddOwnerResultMock = createCustomMutationResult({ status: 'idle', mutateFnName: 'addOwner' }) as unknown as useAddOwner.UseAddOwnerReturnType - const useRemoveOwnerResultMock = getCustomMutationResult({ + const useRemoveOwnerResultMock = createCustomMutationResult({ status: 'idle', mutateFnName: 'removeOwner' }) as unknown as useRemoveOwner.UseRemoveOwnerReturnType - const useSwapOwnerResultMock = getCustomMutationResult({ + const useSwapOwnerResultMock = createCustomMutationResult({ status: 'idle', mutateFnName: 'swapOwner' }) as unknown as useSwapOwner.UseSwapOwnerReturnType diff --git a/src/hooks/useUpdateOwners/useAddOwner.test.ts b/src/hooks/useUpdateOwners/useAddOwner.test.ts index b0fb537..51c5f29 100644 --- a/src/hooks/useUpdateOwners/useAddOwner.test.ts +++ b/src/hooks/useUpdateOwners/useAddOwner.test.ts @@ -11,7 +11,7 @@ import { signerPrivateKeys } from '@test/fixtures/index.js' import { configPredictedSafe } from '@test/config.js' -import { getCustomMutationResult } from '@test/fixtures/mutationResult.js' +import { createCustomMutationResult } from '@test/fixtures/mutationResult.js' import { renderHookInQueryClientProvider } from '@test/utils.js' import { MutationKey } from '@/constants.js' @@ -20,8 +20,8 @@ describe('useAddOwner', () => { const mutateFnName = 'addOwner' const variables = { ownerAddress } - const mutationIdleResult = getCustomMutationResult({ status: 'idle', mutateFnName }) - const mutationSuccessResult = getCustomMutationResult({ + const mutationIdleResult = createCustomMutationResult({ status: 'idle', mutateFnName }) + const mutationSuccessResult = createCustomMutationResult({ status: 'success', mutateFnName, data: ethereumTxHash, @@ -143,7 +143,7 @@ describe('useAddOwner', () => { 'if creating a transaction for adding an owner fails for `%s`', async (fnName) => { const error = new Error('Error creating add owner transaction') - const mutationErrorResult = getCustomMutationResult({ + const mutationErrorResult = createCustomMutationResult({ status: 'error', mutateFnName, error, @@ -175,7 +175,7 @@ describe('useAddOwner', () => { 'if sending a transaction for adding an owner fails for `%s`', async (fnName) => { const error = new Error('Error sending add owner transaction') - const mutationErrorResult = getCustomMutationResult({ + const mutationErrorResult = createCustomMutationResult({ status: 'error', mutateFnName, error, diff --git a/src/hooks/useUpdateOwners/useRemoveOwner.test.ts b/src/hooks/useUpdateOwners/useRemoveOwner.test.ts index 6f0306d..8c65b2c 100644 --- a/src/hooks/useUpdateOwners/useRemoveOwner.test.ts +++ b/src/hooks/useUpdateOwners/useRemoveOwner.test.ts @@ -11,7 +11,7 @@ import { signerPrivateKeys } from '@test/fixtures/index.js' import { configPredictedSafe } from '@test/config.js' -import { getCustomMutationResult } from '@test/fixtures/mutationResult.js' +import { createCustomMutationResult } from '@test/fixtures/mutationResult.js' import { renderHookInQueryClientProvider } from '@test/utils.js' import { MutationKey } from '@/constants.js' @@ -20,8 +20,8 @@ describe('useRemoveOwner', () => { const mutateFnName = 'removeOwner' const variables = { ownerAddress } - const mutationIdleResult = getCustomMutationResult({ status: 'idle', mutateFnName }) - const mutationSuccessResult = getCustomMutationResult({ + const mutationIdleResult = createCustomMutationResult({ status: 'idle', mutateFnName }) + const mutationSuccessResult = createCustomMutationResult({ status: 'success', mutateFnName, data: ethereumTxHash, @@ -144,7 +144,7 @@ describe('useRemoveOwner', () => { 'if creating a transaction for removing an owner fails for `%s`', async (fnName) => { const error = new Error('Error creating remove owner transaction') - const mutationErrorResult = getCustomMutationResult({ + const mutationErrorResult = createCustomMutationResult({ status: 'error', mutateFnName, error, @@ -176,7 +176,7 @@ describe('useRemoveOwner', () => { 'if sending a transaction for removing an owner fails for `%s`', async (fnName) => { const error = new Error('Error sending remove owner transaction') - const mutationErrorResult = getCustomMutationResult({ + const mutationErrorResult = createCustomMutationResult({ status: 'error', mutateFnName, error, diff --git a/src/hooks/useUpdateOwners/useSwapOwner.test.ts b/src/hooks/useUpdateOwners/useSwapOwner.test.ts index eb63153..8a84e51 100644 --- a/src/hooks/useUpdateOwners/useSwapOwner.test.ts +++ b/src/hooks/useUpdateOwners/useSwapOwner.test.ts @@ -11,15 +11,15 @@ import { signerPrivateKeys } from '@test/fixtures/index.js' import { configPredictedSafe } from '@test/config.js' -import { getCustomMutationResult } from '@test/fixtures/mutationResult.js' +import { createCustomMutationResult } from '@test/fixtures/mutationResult.js' import { renderHookInQueryClientProvider } from '@test/utils.js' import { MutationKey } from '@/constants.js' describe('useSwapOwner', () => { const mutateFnName = 'swapOwner' const variables = { oldOwnerAddress: accounts[0], newOwnerAddress: accounts[1] } - const mutationIdleResult = getCustomMutationResult({ status: 'idle', mutateFnName }) - const mutationSuccessResult = getCustomMutationResult({ + const mutationIdleResult = createCustomMutationResult({ status: 'idle', mutateFnName }) + const mutationSuccessResult = createCustomMutationResult({ status: 'success', mutateFnName, data: ethereumTxHash, @@ -141,7 +141,7 @@ describe('useSwapOwner', () => { 'if creating a transaction for swapping an owner fails for `%s`', async (fnName) => { const error = new Error('Error creating swapt owner transaction') - const mutationErrorResult = getCustomMutationResult({ + const mutationErrorResult = createCustomMutationResult({ status: 'error', mutateFnName, error, @@ -173,7 +173,7 @@ describe('useSwapOwner', () => { 'if sending a transaction for swapping an owner fails for `%s`', async (fnName) => { const error = new Error('Error sending swap owner transaction') - const mutationErrorResult = getCustomMutationResult({ + const mutationErrorResult = createCustomMutationResult({ status: 'error', mutateFnName, error, diff --git a/src/hooks/useUpdateThreshold.test.ts b/src/hooks/useUpdateThreshold.test.ts index 63dd5f3..4e85ccc 100644 --- a/src/hooks/useUpdateThreshold.test.ts +++ b/src/hooks/useUpdateThreshold.test.ts @@ -6,7 +6,7 @@ import * as useSendTransaction from '@/hooks/useSendTransaction.js' import * as useSignerClientMutation from '@/hooks/useSignerClientMutation.js' import { ethereumTxHash, safeMultisigTransaction, signerPrivateKeys } from '@test/fixtures/index.js' import { configPredictedSafe } from '@test/config.js' -import { getCustomMutationResult } from '@test/fixtures/mutationResult.js' +import { createCustomMutationResult } from '@test/fixtures/mutationResult.js' import { renderHookInQueryClientProvider } from '@test/utils.js' import { MutationKey } from '@/constants.js' @@ -17,8 +17,8 @@ describe('useUpdateThreshold', () => { const mutateFnName = 'updateThreshold' const variables = { threshold } - const mutationIdleResult = getCustomMutationResult({ status: 'idle', mutateFnName }) - const mutationSuccessResult = getCustomMutationResult({ + const mutationIdleResult = createCustomMutationResult({ status: 'idle', mutateFnName }) + const mutationSuccessResult = createCustomMutationResult({ status: 'success', mutateFnName, data: sendTransactionResultMock, @@ -140,7 +140,7 @@ describe('useUpdateThreshold', () => { 'updateThresholdAsync' ])('if creating a transaction for updating the thresholds fails for `%s`', async (fnName) => { const error = new Error('Error creating transaction') - const mutationErrorResult = getCustomMutationResult({ + const mutationErrorResult = createCustomMutationResult({ status: 'error', mutateFnName, error, @@ -172,7 +172,7 @@ describe('useUpdateThreshold', () => { 'updateThresholdAsync' ])('if sending the threshold update transaction fails for `%s`', async (fnName) => { const error = new Error('Error sending transaction') - const mutationErrorResult = getCustomMutationResult({ + const mutationErrorResult = createCustomMutationResult({ status: 'error', mutateFnName, error, diff --git a/test/fixtures/mutationResult.ts b/test/fixtures/mutationResult.ts index ce5fde8..0ea64cc 100644 --- a/test/fixtures/mutationResult.ts +++ b/test/fixtures/mutationResult.ts @@ -57,7 +57,7 @@ const resultMapping = { error: mutationErrorResult } -export function getCustomMutationResult< +export function createCustomMutationResult< TStatus extends keyof typeof resultMapping, TMutateFnName extends string, TResult = (typeof resultMapping)[TStatus], From 37207ab47cf0f5c0e52d501a764282009b299205 Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Wed, 2 Oct 2024 13:29:20 +0200 Subject: [PATCH 24/29] refactor: Add `createCustomQueryResult` for consistency with createCustomMutationResult --- src/hooks/useAuthenticate.test.ts | 11 +++++++--- src/hooks/useSafeInfo/index.test.ts | 31 +++++++++++++++++++++-------- test/fixtures/queryResult.ts | 29 ++++++++++++++++++++++----- 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/src/hooks/useAuthenticate.test.ts b/src/hooks/useAuthenticate.test.ts index cbe8f39..1342193 100644 --- a/src/hooks/useAuthenticate.test.ts +++ b/src/hooks/useAuthenticate.test.ts @@ -6,7 +6,7 @@ import * as useOwners from '@/hooks/useSafeInfo/useOwners.js' import * as useSignerAddress from '@/hooks/useSignerAddress.js' import { renderHookInMockedSafeProvider } from '@test/utils.js' import { safeInfo, signerPrivateKeys } from '@test/fixtures/index.js' -import { createQuerySuccessResult } from '@test/fixtures/queryResult.js' +import { createCustomQueryResult } from '@test/fixtures/queryResult.js' import { SafeContextType } from '@/SafeContext.js' describe('useAuthenticate', () => { @@ -40,7 +40,10 @@ describe('useAuthenticate', () => { return renderResult } - const ownersQueryResultMock = createQuerySuccessResult(safeInfo.owners) + const ownersQueryResultMock = createCustomQueryResult({ + status: 'success', + data: safeInfo.owners + }) beforeEach(() => { useOwnersSpy.mockReturnValue(ownersQueryResultMock) @@ -53,7 +56,9 @@ describe('useAuthenticate', () => { it('if connected signer is not owner of the Safe `isOwnerConnected` should be false', async () => { useSignerAddressSpy.mockReturnValueOnce(safeInfo.owners[0]) - useOwnersSpy.mockReturnValueOnce(createQuerySuccessResult(safeInfo.owners.slice(1))) + useOwnersSpy.mockReturnValueOnce( + createCustomQueryResult({ status: 'success', data: safeInfo.owners.slice(1) }) + ) const { result: { diff --git a/src/hooks/useSafeInfo/index.test.ts b/src/hooks/useSafeInfo/index.test.ts index 77eb97d..da37921 100644 --- a/src/hooks/useSafeInfo/index.test.ts +++ b/src/hooks/useSafeInfo/index.test.ts @@ -7,7 +7,7 @@ import * as useOwners from '@/hooks/useSafeInfo/useOwners.js' import { configPredictedSafe } from '@test/config.js' import { renderHookInQueryClientProvider } from '@test/utils.js' import { - createQuerySuccessResult, + createCustomQueryResult, queryLoadingErrorResult, queryPendingResult } from '@test/fixtures/queryResult.js' @@ -20,11 +20,23 @@ describe('useSafeInfo', () => { const useIsDeployedSpy = jest.spyOn(useIsDeployed, 'useIsDeployed') const useOwnersSpy = jest.spyOn(useOwners, 'useOwners') - const addressQueryResultMock = createQuerySuccessResult(safeInfo.address) - const nonceQueryResultMock = createQuerySuccessResult(safeInfo.nonce) - const thresholdQueryResultMock = createQuerySuccessResult(safeInfo.threshold) - const isDeployedQueryResultMock = createQuerySuccessResult(safeInfo.isDeployed) - const ownersQueryResultMock = createQuerySuccessResult(safeInfo.owners) + const addressQueryResultMock = createCustomQueryResult({ + status: 'success', + data: safeInfo.address + }) + const nonceQueryResultMock = createCustomQueryResult({ status: 'success', data: safeInfo.nonce }) + const thresholdQueryResultMock = createCustomQueryResult({ + status: 'success', + data: safeInfo.threshold + }) + const isDeployedQueryResultMock = createCustomQueryResult({ + status: 'success', + data: safeInfo.isDeployed + }) + const ownersQueryResultMock = createCustomQueryResult({ + status: 'success', + data: safeInfo.owners + }) beforeEach(() => { useAddressSpy.mockReturnValue(addressQueryResultMock) @@ -39,7 +51,10 @@ describe('useSafeInfo', () => { }) it('should return fetch and return Safe infos using individual hooks', () => { - const { refetch, ...expectedResult } = createQuerySuccessResult(safeInfo) + const { refetch, ...expectedResult } = createCustomQueryResult({ + status: 'success', + data: safeInfo + }) const { result } = renderHookInQueryClientProvider(() => useSafeInfo()) @@ -81,7 +96,7 @@ describe('useSafeInfo', () => { }) it('should return with loading state + partial data if any individual hook returns with loading state', async () => { - useThresholdSpy.mockReturnValueOnce(queryPendingResult) + useThresholdSpy.mockReturnValueOnce(createCustomQueryResult({ status: 'pending' })) const { refetch, ...expectedResult } = { ...queryPendingResult, diff --git a/test/fixtures/queryResult.ts b/test/fixtures/queryResult.ts index 613549d..4fa5eac 100644 --- a/test/fixtures/queryResult.ts +++ b/test/fixtures/queryResult.ts @@ -1,6 +1,7 @@ import { QueryObserverLoadingErrorResult, QueryObserverPendingResult, + QueryObserverResult, QueryObserverSuccessResult } from '@tanstack/react-query' @@ -43,7 +44,7 @@ export const queryLoadingErrorResult: QueryObserverLoadingErrorResult = { isRefetchError: false, isSuccess: false, status: 'error', - failureReason: new Error('Something went wrong'), + failureReason: new Error('Query failed :('), errorUpdateCount: 1, isFetched: false, isFetchedAfterMount: false, @@ -53,13 +54,12 @@ export const queryLoadingErrorResult: QueryObserverLoadingErrorResult = { isPlaceholderData: false, isRefetching: false, isStale: false, - refetch: refetchMock, fetchStatus: 'idle' } -export const createQuerySuccessResult = (data: T): QueryObserverSuccessResult => ({ +export const querySuccessResult: QueryObserverSuccessResult = { ...queryPendingResult, - data, + data: undefined, isPending: false, isLoading: false, isLoadingError: false, @@ -71,4 +71,23 @@ export const createQuerySuccessResult = (data: T): QueryObserverSuccessResult isFetching: false, refetch: refetchMock, fetchStatus: 'idle' -}) +} + +const resultMapping = { + pending: queryPendingResult, + error: queryLoadingErrorResult, + success: querySuccessResult +} + +export function createCustomQueryResult< + TStatus extends keyof typeof resultMapping, + TData = TStatus extends 'success' ? unknown : undefined, + TError extends Error = Error +>({ status, data, error }: { status: TStatus; data?: TData; error?: TError }) { + const result = resultMapping[status] + return { + ...result, + ...(status === 'success' && data ? { data } : {}), + ...(status === 'error' && error ? { error, failureReason: error } : {}) + } as QueryObserverResult +} From 05c3a3ef8bdc15f63ec5a44669a99613399c3fef Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Wed, 2 Oct 2024 17:09:41 +0200 Subject: [PATCH 25/29] refactor: Call functions directly from SafeClient instance, instead of from `SafeClient.protocolKit` --- src/hooks/useSafeInfo/useAddress.test.ts | 2 +- src/hooks/useSafeInfo/useAddress.ts | 2 +- src/hooks/useSafeInfo/useIsDeployed.test.ts | 10 ++++------ src/hooks/useSafeInfo/useIsDeployed.ts | 2 +- src/hooks/useSafeInfo/useNonce.test.ts | 4 +--- src/hooks/useSafeInfo/useNonce.ts | 2 +- src/hooks/useSafeInfo/useOwners.test.ts | 4 +--- src/hooks/useSafeInfo/useOwners.ts | 3 +-- src/hooks/useSafeInfo/useThreshold.test.ts | 4 +--- src/hooks/useSafeInfo/useThreshold.ts | 2 +- src/hooks/useUpdateOwners/useAddOwner.test.ts | 2 +- src/hooks/useUpdateOwners/useAddOwner.ts | 4 ++-- .../useUpdateOwners/useRemoveOwner.test.ts | 2 +- src/hooks/useUpdateOwners/useRemoveOwner.ts | 4 ++-- src/hooks/useUpdateOwners/useSwapOwner.test.ts | 2 +- src/hooks/useUpdateOwners/useSwapOwner.ts | 4 ++-- src/hooks/useUpdateThreshold.test.ts | 18 +++++++++--------- src/hooks/useUpdateThreshold.ts | 6 ++++-- 18 files changed, 35 insertions(+), 42 deletions(-) diff --git a/src/hooks/useSafeInfo/useAddress.test.ts b/src/hooks/useSafeInfo/useAddress.test.ts index 71ca98d..ed4f58d 100644 --- a/src/hooks/useSafeInfo/useAddress.test.ts +++ b/src/hooks/useSafeInfo/useAddress.test.ts @@ -17,7 +17,7 @@ describe('useAddress', () => { status: 'success' } as unknown as UseQueryResult - const publicClientMock = { protocolKit: { getAddress: getAddressMock } } as unknown as SafeClient + const publicClientMock = { getAddress: getAddressMock } as unknown as SafeClient beforeEach(() => { usePublicClientQuerySpy.mockImplementation(({ querySafeClientFn }) => { diff --git a/src/hooks/useSafeInfo/useAddress.ts b/src/hooks/useSafeInfo/useAddress.ts index 90a5a3e..488f9fe 100644 --- a/src/hooks/useSafeInfo/useAddress.ts +++ b/src/hooks/useSafeInfo/useAddress.ts @@ -17,7 +17,7 @@ export function useAddress(params: UseAddressParams = {}): UseAddressReturnType return usePublicClientQuery({ ...params, querySafeClientFn: (safeClient) => - safeClient.protocolKit.getAddress().then((address) => address as Address), + safeClient.getAddress().then((address) => address as Address), queryKey: [QueryKey.Address] }) } diff --git a/src/hooks/useSafeInfo/useIsDeployed.test.ts b/src/hooks/useSafeInfo/useIsDeployed.test.ts index 2723d1b..d61e298 100644 --- a/src/hooks/useSafeInfo/useIsDeployed.test.ts +++ b/src/hooks/useSafeInfo/useIsDeployed.test.ts @@ -9,16 +9,14 @@ import { QueryKey } from '@/constants.js' describe('useIsDeployed', () => { const usePublicClientQuerySpy = jest.spyOn(usePublicClientQuery, 'usePublicClientQuery') - const isSafeDeployedMock = jest.fn().mockResolvedValue(true) + const isDeployedMock = jest.fn().mockResolvedValue(true) const isDeployedQueryResultMock = { data: true, status: 'success' } as unknown as UseQueryResult - const publicClientMock = { - protocolKit: { isSafeDeployed: isSafeDeployedMock } - } as unknown as SafeClient + const publicClientMock = { isDeployed: isDeployedMock } as unknown as SafeClient beforeEach(() => { usePublicClientQuerySpy.mockImplementation(({ querySafeClientFn }) => { @@ -40,8 +38,8 @@ describe('useIsDeployed', () => { queryKey: [QueryKey.IsDeployed] }) - expect(isSafeDeployedMock).toHaveBeenCalledTimes(1) - expect(isSafeDeployedMock).toHaveBeenCalledWith() + expect(isDeployedMock).toHaveBeenCalledTimes(1) + expect(isDeployedMock).toHaveBeenCalledWith() expect(result.current).toEqual(isDeployedQueryResultMock) }) diff --git a/src/hooks/useSafeInfo/useIsDeployed.ts b/src/hooks/useSafeInfo/useIsDeployed.ts index 09ee89d..4f9860f 100644 --- a/src/hooks/useSafeInfo/useIsDeployed.ts +++ b/src/hooks/useSafeInfo/useIsDeployed.ts @@ -15,7 +15,7 @@ export type UseIsDeployedReturnType = UseQueryResult export function useIsDeployed(params: UseIsDeployedParams = {}): UseIsDeployedReturnType { return usePublicClientQuery({ ...params, - querySafeClientFn: (safeClient) => safeClient.protocolKit.isSafeDeployed(), + querySafeClientFn: (safeClient) => safeClient.isDeployed(), queryKey: [QueryKey.IsDeployed] }) } diff --git a/src/hooks/useSafeInfo/useNonce.test.ts b/src/hooks/useSafeInfo/useNonce.test.ts index 4b6e17e..bac8e4e 100644 --- a/src/hooks/useSafeInfo/useNonce.test.ts +++ b/src/hooks/useSafeInfo/useNonce.test.ts @@ -17,9 +17,7 @@ describe('useNonce', () => { status: 'success' } as unknown as UseQueryResult - const publicClientMock = { - protocolKit: { getNonce: getNonceMock } - } as unknown as SafeClient + const publicClientMock = { getNonce: getNonceMock } as unknown as SafeClient beforeEach(() => { usePublicClientQuerySpy.mockImplementation(({ querySafeClientFn }) => { diff --git a/src/hooks/useSafeInfo/useNonce.ts b/src/hooks/useSafeInfo/useNonce.ts index 1bea665..2601dd1 100644 --- a/src/hooks/useSafeInfo/useNonce.ts +++ b/src/hooks/useSafeInfo/useNonce.ts @@ -15,7 +15,7 @@ export type UseNonceReturnType = UseQueryResult export function useNonce(params: UseNonceParams = {}): UseNonceReturnType { return usePublicClientQuery({ ...params, - querySafeClientFn: (safeClient) => safeClient.protocolKit.getNonce(), + querySafeClientFn: (safeClient) => safeClient.getNonce(), queryKey: [QueryKey.Nonce] }) } diff --git a/src/hooks/useSafeInfo/useOwners.test.ts b/src/hooks/useSafeInfo/useOwners.test.ts index dfa6451..fdf5148 100644 --- a/src/hooks/useSafeInfo/useOwners.test.ts +++ b/src/hooks/useSafeInfo/useOwners.test.ts @@ -18,9 +18,7 @@ describe('useOwners', () => { status: 'success' } as unknown as UseQueryResult - const publicClientMock = { - protocolKit: { getOwners: getOwnersMock } - } as unknown as SafeClient + const publicClientMock = { getOwners: getOwnersMock } as unknown as SafeClient beforeEach(() => { usePublicClientQuerySpy.mockImplementation(({ querySafeClientFn }) => { diff --git a/src/hooks/useSafeInfo/useOwners.ts b/src/hooks/useSafeInfo/useOwners.ts index cae11a4..2556478 100644 --- a/src/hooks/useSafeInfo/useOwners.ts +++ b/src/hooks/useSafeInfo/useOwners.ts @@ -16,8 +16,7 @@ export type UseOwnersReturnType = UseQueryResult export function useOwners(params: UseOwnersParams = {}): UseOwnersReturnType { return usePublicClientQuery({ ...params, - querySafeClientFn: (safeClient) => - safeClient.protocolKit.getOwners().then((owners) => owners as Address[]), + querySafeClientFn: (safeClient) => safeClient.getOwners().then((owners) => owners as Address[]), queryKey: [QueryKey.Owners] }) } diff --git a/src/hooks/useSafeInfo/useThreshold.test.ts b/src/hooks/useSafeInfo/useThreshold.test.ts index af98955..a58440a 100644 --- a/src/hooks/useSafeInfo/useThreshold.test.ts +++ b/src/hooks/useSafeInfo/useThreshold.test.ts @@ -17,9 +17,7 @@ describe('useThreshold', () => { status: 'success' } as unknown as UseQueryResult - const publicClientMock = { - protocolKit: { getThreshold: getThresholdMock } - } as unknown as SafeClient + const publicClientMock = { getThreshold: getThresholdMock } as unknown as SafeClient beforeEach(() => { usePublicClientQuerySpy.mockImplementation(({ querySafeClientFn }) => { diff --git a/src/hooks/useSafeInfo/useThreshold.ts b/src/hooks/useSafeInfo/useThreshold.ts index 613b19f..fbf82a8 100644 --- a/src/hooks/useSafeInfo/useThreshold.ts +++ b/src/hooks/useSafeInfo/useThreshold.ts @@ -15,7 +15,7 @@ export type UseThresholdReturnType = UseQueryResult export function useThreshold(params: UseThresholdParams = {}): UseThresholdReturnType { return usePublicClientQuery({ ...params, - querySafeClientFn: (safeClient) => safeClient.protocolKit.getThreshold(), + querySafeClientFn: (safeClient) => safeClient.getThreshold(), queryKey: [QueryKey.Threshold] }) } diff --git a/src/hooks/useUpdateOwners/useAddOwner.test.ts b/src/hooks/useUpdateOwners/useAddOwner.test.ts index 51c5f29..ab896f4 100644 --- a/src/hooks/useUpdateOwners/useAddOwner.test.ts +++ b/src/hooks/useUpdateOwners/useAddOwner.test.ts @@ -38,7 +38,7 @@ describe('useAddOwner', () => { const sendTransactionAsyncMock = jest.fn().mockResolvedValue(sendTransactionResultMock) const signerClientMock = { - protocolKit: { createAddOwnerTx: createAddOwnerTxMock } + createAddOwnerTransaction: createAddOwnerTxMock } as unknown as SafeClient beforeEach(() => { diff --git a/src/hooks/useUpdateOwners/useAddOwner.ts b/src/hooks/useUpdateOwners/useAddOwner.ts index 904609a..2404138 100644 --- a/src/hooks/useUpdateOwners/useAddOwner.ts +++ b/src/hooks/useUpdateOwners/useAddOwner.ts @@ -5,7 +5,7 @@ import { useSignerClientMutation } from '@/hooks/useSignerClientMutation.js' import { useSendTransaction } from '@/hooks/useSendTransaction.js' import { MutationKey } from '@/constants.js' -type AddOwnerVariables = Parameters[0] +type AddOwnerVariables = Parameters[0] export type UseAddOwnerParams = ConfigParam export type UseAddOwnerReturnType = Omit< @@ -32,7 +32,7 @@ export function useAddOwner(params: UseAddOwnerParams = {}): UseAddOwnerReturnTy ...params, mutationKey: [MutationKey.AddOwner], mutationSafeClientFn: async (safeClient, params) => { - const addOwnerTx = await safeClient.protocolKit.createAddOwnerTx(params) + const addOwnerTx = await safeClient.createAddOwnerTransaction(params) return sendTransactionAsync({ transactions: [addOwnerTx] }) } }) diff --git a/src/hooks/useUpdateOwners/useRemoveOwner.test.ts b/src/hooks/useUpdateOwners/useRemoveOwner.test.ts index 8c65b2c..8afd089 100644 --- a/src/hooks/useUpdateOwners/useRemoveOwner.test.ts +++ b/src/hooks/useUpdateOwners/useRemoveOwner.test.ts @@ -38,7 +38,7 @@ describe('useRemoveOwner', () => { const sendTransactionAsyncMock = jest.fn().mockResolvedValue(sendTransactionResultMock) const signerClientMock = { - protocolKit: { createRemoveOwnerTx: createRemoveOwnerTxMock } + createRemoveOwnerTransaction: createRemoveOwnerTxMock } as unknown as SafeClient beforeEach(() => { diff --git a/src/hooks/useUpdateOwners/useRemoveOwner.ts b/src/hooks/useUpdateOwners/useRemoveOwner.ts index 794ea51..d834b0a 100644 --- a/src/hooks/useUpdateOwners/useRemoveOwner.ts +++ b/src/hooks/useUpdateOwners/useRemoveOwner.ts @@ -5,7 +5,7 @@ import { useSendTransaction } from '@/hooks/useSendTransaction.js' import { useSignerClientMutation } from '@/hooks/useSignerClientMutation.js' import { MutationKey } from '@/constants.js' -type RemoveOwnerVariables = Parameters[0] +type RemoveOwnerVariables = Parameters[0] export type UseRemoveOwnerParams = ConfigParam export type UseRemoveOwnerReturnType = Omit< @@ -32,7 +32,7 @@ export function useRemoveOwner(params: UseRemoveOwnerParams = {}): UseRemoveOwne ...params, mutationKey: [MutationKey.RemoveOwner], mutationSafeClientFn: async (safeClient, params) => { - const removeOwnerTx = await safeClient.protocolKit.createRemoveOwnerTx(params) + const removeOwnerTx = await safeClient.createRemoveOwnerTransaction(params) return sendTransactionAsync({ transactions: [removeOwnerTx] }) } }) diff --git a/src/hooks/useUpdateOwners/useSwapOwner.test.ts b/src/hooks/useUpdateOwners/useSwapOwner.test.ts index 8a84e51..fc03f8b 100644 --- a/src/hooks/useUpdateOwners/useSwapOwner.test.ts +++ b/src/hooks/useUpdateOwners/useSwapOwner.test.ts @@ -36,7 +36,7 @@ describe('useSwapOwner', () => { const sendTransactionAsyncMock = jest.fn().mockResolvedValue(sendTransactionResultMock) const signerClientMock = { - protocolKit: { createSwapOwnerTx: createSwapOwnerTxMock } + createSwapOwnerTransaction: createSwapOwnerTxMock } as unknown as SafeClient beforeEach(() => { diff --git a/src/hooks/useUpdateOwners/useSwapOwner.ts b/src/hooks/useUpdateOwners/useSwapOwner.ts index 5e67402..0f077af 100644 --- a/src/hooks/useUpdateOwners/useSwapOwner.ts +++ b/src/hooks/useUpdateOwners/useSwapOwner.ts @@ -5,7 +5,7 @@ import { useSignerClientMutation } from '@/hooks/useSignerClientMutation.js' import { useSendTransaction } from '@/hooks/useSendTransaction.js' import { MutationKey } from '@/constants.js' -type SwapOwnerVariables = Parameters[0] +type SwapOwnerVariables = Parameters[0] export type UseSwapOwnerParams = ConfigParam export type UseSwapOwnerReturnType = Omit< @@ -32,7 +32,7 @@ export function useSwapOwner(params: UseSwapOwnerParams = {}): UseSwapOwnerRetur ...params, mutationKey: [MutationKey.SwapOwner], mutationSafeClientFn: async (safeClient, params) => { - const swapOwnerTx = await safeClient.protocolKit.createSwapOwnerTx(params) + const swapOwnerTx = await safeClient.createSwapOwnerTransaction(params) return sendTransactionAsync({ transactions: [swapOwnerTx] }) } }) diff --git a/src/hooks/useUpdateThreshold.test.ts b/src/hooks/useUpdateThreshold.test.ts index 4e85ccc..6a4636d 100644 --- a/src/hooks/useUpdateThreshold.test.ts +++ b/src/hooks/useUpdateThreshold.test.ts @@ -33,7 +33,7 @@ describe('useUpdateThreshold', () => { const sendTransactionAsyncMock = jest.fn().mockResolvedValue(sendTransactionResultMock) const signerClientMock = { - protocolKit: { createChangeThresholdTx: createChangeThresholdTxMock } + createChangeThresholdTransaction: createChangeThresholdTxMock } as unknown as SafeClient beforeEach(() => { @@ -114,7 +114,7 @@ describe('useUpdateThreshold', () => { expect(createChangeThresholdTxMock).toHaveBeenCalledTimes(0) expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(0) - const updateThresholdResult = await result.current[fnName]({ threshold }) + const updateThresholdResult = await result.current[fnName](variables) if (fnName === 'updateThresholdAsync') { expect(updateThresholdResult).toEqual(sendTransactionResultMock) @@ -125,7 +125,7 @@ describe('useUpdateThreshold', () => { expect(result.current).toEqual(mutationSuccessResult) expect(createChangeThresholdTxMock).toHaveBeenCalledTimes(1) - expect(createChangeThresholdTxMock).toHaveBeenCalledWith(threshold) + expect(createChangeThresholdTxMock).toHaveBeenCalledWith(variables) expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(1) expect(sendTransactionAsyncMock).toHaveBeenCalledWith({ @@ -152,9 +152,9 @@ describe('useUpdateThreshold', () => { const { result } = renderHookInQueryClientProvider(() => useUpdateThreshold()) if (fnName === 'updateThresholdAsync') { - await expect(result.current.updateThresholdAsync({ threshold })).rejects.toEqual(error) + await expect(result.current.updateThresholdAsync(variables)).rejects.toEqual(error) } else { - result.current.updateThreshold({ threshold }) + result.current.updateThreshold(variables) } await waitFor(() => expect(result.current.isError).toBeTruthy()) @@ -162,7 +162,7 @@ describe('useUpdateThreshold', () => { expect(result.current).toEqual(mutationErrorResult) expect(createChangeThresholdTxMock).toHaveBeenCalledTimes(1) - expect(createChangeThresholdTxMock).toHaveBeenCalledWith(threshold) + expect(createChangeThresholdTxMock).toHaveBeenCalledWith(variables) expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(0) }) @@ -184,9 +184,9 @@ describe('useUpdateThreshold', () => { const { result } = renderHookInQueryClientProvider(() => useUpdateThreshold()) if (fnName === 'updateThresholdAsync') { - await expect(result.current.updateThresholdAsync({ threshold })).rejects.toEqual(error) + await expect(result.current.updateThresholdAsync(variables)).rejects.toEqual(error) } else { - result.current.updateThreshold({ threshold }) + result.current.updateThreshold(variables) } await waitFor(() => expect(result.current.isError).toBeTruthy()) @@ -194,7 +194,7 @@ describe('useUpdateThreshold', () => { expect(result.current).toEqual(mutationErrorResult) expect(createChangeThresholdTxMock).toHaveBeenCalledTimes(1) - expect(createChangeThresholdTxMock).toHaveBeenCalledWith(threshold) + expect(createChangeThresholdTxMock).toHaveBeenCalledWith(variables) expect(sendTransactionAsyncMock).toHaveBeenCalledTimes(1) expect(sendTransactionAsyncMock).toHaveBeenCalledWith({ diff --git a/src/hooks/useUpdateThreshold.ts b/src/hooks/useUpdateThreshold.ts index 8cd9e8f..129ecd5 100644 --- a/src/hooks/useUpdateThreshold.ts +++ b/src/hooks/useUpdateThreshold.ts @@ -38,8 +38,10 @@ export function useUpdateThreshold( >({ ...params, mutationKey: [MutationKey.UpdateThreshold], - mutationSafeClientFn: async (signerClient, { threshold }) => { - const updateThresholdTx = await signerClient.protocolKit.createChangeThresholdTx(threshold) + mutationSafeClientFn: async (signerClient, updateThresholdParams) => { + const updateThresholdTx = await signerClient.createChangeThresholdTransaction( + updateThresholdParams + ) return sendTransactionAsync({ transactions: [updateThresholdTx] }) } }) From 09b3ed60375bf2f46838cd87547080282be2d019 Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Wed, 2 Oct 2024 17:12:39 +0200 Subject: [PATCH 26/29] refactor: Improve useAuthenticate hook Call `signerClient.isOwner` instead of checking owners array to compute `isOwnerConnected`. Throw error if not rendered in SafeProvider --- src/hooks/useAuthenticate.test.ts | 81 +++++++++++++++++++------------ src/hooks/useAuthenticate.ts | 26 +++++++--- test/utils.ts | 17 ++++--- 3 files changed, 77 insertions(+), 47 deletions(-) diff --git a/src/hooks/useAuthenticate.test.ts b/src/hooks/useAuthenticate.test.ts index 1342193..11c675c 100644 --- a/src/hooks/useAuthenticate.test.ts +++ b/src/hooks/useAuthenticate.test.ts @@ -2,17 +2,16 @@ import { act } from 'react' import { waitFor } from '@testing-library/react' import { SafeClient } from '@safe-global/sdk-starter-kit' import { useAuthenticate, UseConnectSignerReturnType } from '@/hooks/useAuthenticate.js' -import * as useOwners from '@/hooks/useSafeInfo/useOwners.js' import * as useSignerAddress from '@/hooks/useSignerAddress.js' -import { renderHookInMockedSafeProvider } from '@test/utils.js' +import { catchHookError, renderHookInMockedSafeProvider } from '@test/utils.js' import { safeInfo, signerPrivateKeys } from '@test/fixtures/index.js' -import { createCustomQueryResult } from '@test/fixtures/queryResult.js' import { SafeContextType } from '@/SafeContext.js' +import { configExistingSafe } from '@test/config.js' describe('useAuthenticate', () => { - const signerClientMock = { safeClient: 'signer' } as unknown as SafeClient + const isOwnerMock = jest.fn() + const signerClientMock = { isOwner: isOwnerMock } as unknown as SafeClient const setSignerMock = jest.fn(() => Promise.resolve()) - const useOwnersSpy = jest.spyOn(useOwners, 'useOwners') const useSignerAddressSpy = jest.spyOn(useSignerAddress, 'useSignerAddress') const renderUseAuthenticate = async ( @@ -22,10 +21,11 @@ describe('useAuthenticate', () => { const renderOptions = { signerClient: signerClientMock, setSigner: setSignerMock, + config: configExistingSafe, ...context } - const renderResult = renderHookInMockedSafeProvider(() => useAuthenticate(), renderOptions) + const renderResult = renderHookInMockedSafeProvider(useAuthenticate, renderOptions) await waitFor(() => expect(renderResult.result.current).toEqual({ @@ -40,37 +40,48 @@ describe('useAuthenticate', () => { return renderResult } - const ownersQueryResultMock = createCustomQueryResult({ - status: 'success', - data: safeInfo.owners - }) - beforeEach(() => { - useOwnersSpy.mockReturnValue(ownersQueryResultMock) + isOwnerMock.mockResolvedValue(true) useSignerAddressSpy.mockReturnValue(safeInfo.owners[1]) }) afterEach(() => { jest.clearAllMocks() + jest.resetAllMocks() }) - it('if connected signer is not owner of the Safe `isOwnerConnected` should be false', async () => { - useSignerAddressSpy.mockReturnValueOnce(safeInfo.owners[0]) - useOwnersSpy.mockReturnValueOnce( - createCustomQueryResult({ status: 'success', data: safeInfo.owners.slice(1) }) - ) + describe('isOwnerConnected', () => { + it('should be true if connected signer is not owner of the Safe', async () => { + useSignerAddressSpy.mockReturnValue(safeInfo.owners[0]) + + const { + result: { + current: { isSignerConnected, isOwnerConnected } + } + } = await renderUseAuthenticate(undefined, { + isSignerConnected: true, + isOwnerConnected: true + }) - const { - result: { - current: { isSignerConnected, isOwnerConnected } - } - } = await renderUseAuthenticate(undefined, { - isSignerConnected: true, - isOwnerConnected: false + expect(isSignerConnected).toBeTruthy() + expect(isOwnerConnected).toBeTruthy() }) - expect(isSignerConnected).toBe(true) - expect(isOwnerConnected).toBe(false) + it('should be false if connected signer is not owner of the Safe', async () => { + useSignerAddressSpy.mockReturnValueOnce(safeInfo.owners[0]) + isOwnerMock.mockResolvedValueOnce(false) + + const { + result: { + current: { isSignerConnected, isOwnerConnected } + } + } = await renderUseAuthenticate(undefined, { + isSignerConnected: true + }) + + expect(isSignerConnected).toBeTruthy() + expect(isOwnerConnected).toBeFalsy() + }) }) describe('connect', () => { @@ -82,8 +93,8 @@ describe('useAuthenticate', () => { await act(() => result.current.connect(signerPrivateKeys[1])) - expect(useOwnersSpy).toHaveBeenCalledTimes(1) - expect(useSignerAddressSpy).toHaveBeenCalledTimes(1) + expect(isOwnerMock).toHaveBeenCalledTimes(1) + expect(useSignerAddressSpy).toHaveBeenCalledTimes(2) expect(setSignerMock).toHaveBeenCalledTimes(1) expect(setSignerMock).toHaveBeenCalledWith(signerPrivateKeys[1]) @@ -98,7 +109,7 @@ describe('useAuthenticate', () => { 'Failed to connect because signer is empty' ) - expect(useOwnersSpy).toHaveBeenCalledTimes(1) + expect(isOwnerMock).toHaveBeenCalledTimes(0) expect(useSignerAddressSpy).toHaveBeenCalledTimes(1) expect(setSignerMock).toHaveBeenCalledTimes(0) @@ -114,8 +125,8 @@ describe('useAuthenticate', () => { await act(() => result.current.disconnect()) - expect(useOwnersSpy).toHaveBeenCalledTimes(1) - expect(useSignerAddressSpy).toHaveBeenCalledTimes(1) + expect(isOwnerMock).toHaveBeenCalledTimes(1) + expect(useSignerAddressSpy).toHaveBeenCalledTimes(2) expect(setSignerMock).toHaveBeenCalledTimes(1) expect(setSignerMock).toHaveBeenCalledWith(undefined) @@ -130,10 +141,16 @@ describe('useAuthenticate', () => { 'Failed to disconnect because no signer is connected' ) - expect(useOwnersSpy).toHaveBeenCalledTimes(1) + expect(isOwnerMock).toHaveBeenCalledTimes(0) expect(useSignerAddressSpy).toHaveBeenCalledTimes(1) expect(setSignerMock).toHaveBeenCalledTimes(0) }) }) + + it('should throw if not used within a `SafeProvider`', async () => { + const error = catchHookError(() => useAuthenticate()) + + expect(error?.message).toEqual('`useAuthenticate` must be used within `SafeProvider`.') + }) }) diff --git a/src/hooks/useAuthenticate.ts b/src/hooks/useAuthenticate.ts index 5796f8c..1d9d7fb 100644 --- a/src/hooks/useAuthenticate.ts +++ b/src/hooks/useAuthenticate.ts @@ -1,8 +1,11 @@ -import { useCallback, useContext, useMemo } from 'react' +import { useCallback, useContext, useEffect, useState } from 'react' import { SafeContext } from '@/SafeContext.js' -import { useOwners } from '@/hooks/useSafeInfo/useOwners.js' import { useSignerAddress } from '@/hooks/useSignerAddress.js' import { AuthenticationError } from '@/errors/AuthenticationError.js' +import { ConfigParam, SafeConfig } from '@/types/index.js' +import { MissingSafeProviderError } from '@/errors/MissingSafeProviderError.js' + +export type UseAuthenticateParams = ConfigParam export type UseConnectSignerReturnType = { connect: (signer: string) => Promise @@ -16,10 +19,16 @@ export type UseConnectSignerReturnType = { * @returns Functions to connect and disconnect a signer. */ export function useAuthenticate(): UseConnectSignerReturnType { - const { signerClient, setSigner } = useContext(SafeContext) - const { data: owners } = useOwners() + const { signerClient, setSigner, config } = useContext(SafeContext) || {} + + if (!config) { + throw new MissingSafeProviderError('`useAuthenticate` must be used within `SafeProvider`.') + } + const signerAddress = useSignerAddress() + const [isOwnerConnected, setIsOwnerConnected] = useState(false) + const connect = useCallback( async (signer: string) => { if (!signer) { @@ -39,10 +48,11 @@ export function useAuthenticate(): UseConnectSignerReturnType { const isSignerConnected = !!signerAddress - const isOwnerConnected = useMemo( - () => !!owners && !!signerAddress && owners.includes(signerAddress), - [owners, signerAddress] - ) + useEffect(() => { + if (signerClient && signerAddress) { + signerClient.isOwner(signerAddress).then(setIsOwnerConnected) + } + }, [signerClient, signerAddress]) return { connect, disconnect, isSignerConnected, isOwnerConnected } } diff --git a/test/utils.ts b/test/utils.ts index f4bd00a..a49fb37 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -1,4 +1,4 @@ -import { createContext, createElement } from 'react' +import React, { createContext, createElement } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { renderHook, RenderHookOptions } from '@testing-library/react' import * as safeContext from '@/SafeContext.js' @@ -46,19 +46,22 @@ export function renderHookInMockedSafeProvider( ...context } - const SafeContextTemp = createContext(contextValue) + const SafeContext = createContext(contextValue) - const OriginalSafeContext = safeContext.SafeContext - ;(safeContext as any).SafeContext = SafeContextTemp + const originalUseContext = React.useContext + + jest + .spyOn(React, 'useContext') + .mockImplementation((context) => + context === safeContext.SafeContext ? contextValue : originalUseContext(context) + ) const renderResult = renderHook(hook, { ...options, wrapper: ({ children }) => - createElement(SafeContextTemp.Provider, { value: contextValue }, children) + createElement(SafeContext.Provider, { value: contextValue }, children) }) - ;(safeContext as any).SafeContext = OriginalSafeContext - return renderResult } From 0395fb294d2c151c02ec73560e91a4436237549b Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Mon, 7 Oct 2024 14:58:38 +0200 Subject: [PATCH 27/29] Fix name of `useUpdateOwners` hook's param type --- src/hooks/useUpdateOwners/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/useUpdateOwners/index.ts b/src/hooks/useUpdateOwners/index.ts index 404fb27..0ed6ac8 100644 --- a/src/hooks/useUpdateOwners/index.ts +++ b/src/hooks/useUpdateOwners/index.ts @@ -3,7 +3,7 @@ import { useAddOwner } from './useAddOwner.js' import { useRemoveOwner } from './useRemoveOwner.js' import { useSwapOwner } from './useSwapOwner.js' -export type UseAddOwnerParams = ConfigParam +export type UseUpdateOwnersParams = ConfigParam export type UseUpdateOwnersReturnType = { add: ReturnType @@ -15,7 +15,7 @@ export type UseUpdateOwnersReturnType = { * Hook to add, remove or swap owners of the connected Safe. * @returns Object wrapping the hooks to update, remove or swap owners. */ -export function useUpdateOwners(params: UseAddOwnerParams = {}): UseUpdateOwnersReturnType { +export function useUpdateOwners(params: UseUpdateOwnersParams = {}): UseUpdateOwnersReturnType { const add = useAddOwner({ config: params.config }) const remove = useRemoveOwner({ config: params.config }) const swap = useSwapOwner({ config: params.config }) From 94873dc5eed296901b5c8847a24e993cd30b2b4d Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Mon, 7 Oct 2024 15:00:06 +0200 Subject: [PATCH 28/29] Improve JSDoc descriptions --- src/hooks/useUpdateOwners/index.ts | 3 ++- src/hooks/useUpdateThreshold.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/hooks/useUpdateOwners/index.ts b/src/hooks/useUpdateOwners/index.ts index 0ed6ac8..2db31ca 100644 --- a/src/hooks/useUpdateOwners/index.ts +++ b/src/hooks/useUpdateOwners/index.ts @@ -12,7 +12,8 @@ export type UseUpdateOwnersReturnType = { } /** - * Hook to add, remove or swap owners of the connected Safe. + * Hook to manage the owners of the Safe account. It provides methods to add a new owner, + * remove an existing one, and to swap an existing owner in favor of a new one. * @returns Object wrapping the hooks to update, remove or swap owners. */ export function useUpdateOwners(params: UseUpdateOwnersParams = {}): UseUpdateOwnersReturnType { diff --git a/src/hooks/useUpdateThreshold.ts b/src/hooks/useUpdateThreshold.ts index 129ecd5..c01bdb7 100644 --- a/src/hooks/useUpdateThreshold.ts +++ b/src/hooks/useUpdateThreshold.ts @@ -22,7 +22,8 @@ export type UseUpdateThresholdReturnType = Omit< } /** - * Hook to update the threshold of the connected Safe. + * Hook to update the threshold of the connected Safe. It sends or (if the current threshold is > 1) proposes + * a transaction to update the threshold of the Safe account and returns the transaction result. * @param params Parameters to customize the hook behavior. * @param params.config SafeConfig to use instead of the one provided by `SafeProvider`. * @returns Object containing the mutation state and the function to update the threshold. From 2d931f45c1f9545b797c3657987fd03f2fd0b2a5 Mon Sep 17 00:00:00 2001 From: Tim Schwarz <4171783+tmjssz@users.noreply.github.com> Date: Tue, 8 Oct 2024 15:00:21 +0200 Subject: [PATCH 29/29] Improve UseTransactionParams The config param can only be defined in combination with the safeTxHash param. --- src/hooks/useSafe.test.ts | 3 ++- src/hooks/useTransaction.ts | 19 ++++++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/hooks/useSafe.test.ts b/src/hooks/useSafe.test.ts index 3a94f9b..ce7da92 100644 --- a/src/hooks/useSafe.test.ts +++ b/src/hooks/useSafe.test.ts @@ -9,6 +9,7 @@ import * as useSignerAddress from '@/hooks/useSignerAddress.js' import * as useTransaction from '@/hooks/useTransaction.js' import * as usePendingTransactions from '@/hooks/usePendingTransactions.js' import * as useTransactions from '@/hooks/useTransactions.js' +import { UseSafeTransactionReturnType } from '@/hooks/useSafeTransaction.js' import { useSafe } from '@/hooks/useSafe.js' import { configExistingSafe } from '@test/config.js' import { @@ -250,7 +251,7 @@ describe('useSafe', () => { useTransactionSpy.mockReturnValue({ data: safeMultisigTransaction, status: 'success' - }) + } as UseSafeTransactionReturnType) const { result } = await renderUseSafeHook() diff --git a/src/hooks/useTransaction.ts b/src/hooks/useTransaction.ts index e463ae0..854ddf7 100644 --- a/src/hooks/useTransaction.ts +++ b/src/hooks/useTransaction.ts @@ -6,11 +6,15 @@ import { import type { ConfigParam, SafeConfig } from '@/types/index.js' import { useSafeTransaction, UseSafeTransactionReturnType } from './useSafeTransaction.js' -export type UseTransactionParams = ConfigParam & - ({ safeTxHash: Hash; ethereumTxHash?: never } | { safeTxHash?: never; ethereumTxHash: Hash }) +export type UseTransactionParams = + | (ConfigParam & { safeTxHash: Hash }) + | { ethereumTxHash: Hash } -export type UseTransactionReturnType = - Params['safeTxHash'] extends Hash ? UseSafeTransactionReturnType : UseTransactionReturnTypeWagmi +export type UseTransactionReturnType = Params extends { + safeTxHash: Hash +} + ? UseSafeTransactionReturnType + : UseTransactionReturnTypeWagmi /** * Hook to get the status of a specific transaction. @@ -23,9 +27,10 @@ export type UseTransactionReturnType = export function useTransaction( params: Params ): UseTransactionReturnType { - if (params.safeTxHash && isHash(params.safeTxHash)) { + if ('safeTxHash' in params && isHash(params.safeTxHash)) { return useSafeTransaction(params) as UseTransactionReturnType } - - return useTransactionWagmi({ hash: params.ethereumTxHash }) as UseTransactionReturnType + return useTransactionWagmi({ + hash: (params as { ethereumTxHash: Hash }).ethereumTxHash + }) as UseTransactionReturnType }