From f3ab748f643245e42f9eee97164a01ef56b654b6 Mon Sep 17 00:00:00 2001 From: AugistineCreates Date: Mon, 22 Jun 2026 08:49:15 +0100 Subject: [PATCH] test: Add comprehensive unit tests for React hooks and stores with Jest coverage --- jest.config.js | 13 ++- package.json | 1 + src/app/hooks/__tests__/test-utils.ts | 27 +++++ src/app/hooks/__tests__/useApi.test.ts | 23 ++++ .../__tests__/useConfirmedMutation.test.ts | 33 ++++++ .../__tests__/useContractMutation.test.ts | 102 ++++++++++++++++++ .../__tests__/useRepaymentOperation.test.ts | 94 ++++++++++++++++ .../__tests__/useTransactionPreview.test.ts | 40 +++++++ .../stores/__tests__/useWalletStore.test.ts | 66 ++++++++++++ 9 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 src/app/hooks/__tests__/test-utils.ts create mode 100644 src/app/hooks/__tests__/useApi.test.ts create mode 100644 src/app/hooks/__tests__/useConfirmedMutation.test.ts create mode 100644 src/app/hooks/__tests__/useContractMutation.test.ts create mode 100644 src/app/hooks/__tests__/useRepaymentOperation.test.ts create mode 100644 src/app/hooks/__tests__/useTransactionPreview.test.ts create mode 100644 src/app/stores/__tests__/useWalletStore.test.ts diff --git a/jest.config.js b/jest.config.js index 8d48ef6..79276c5 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,11 +7,22 @@ const createJestConfig = nextJest({ const customJestConfig = { setupFilesAfterEnv: ["/jest.setup.ts"], - testEnvironment: "jest-environment-jsdom", + transform: { '^.+\\\\.(ts|tsx)$': 'ts-jest' }, moduleNameMapper: { "^@/(.*)$": "/src/$1", }, testPathIgnorePatterns: ["/e2e/", "/node_modules/"], + collectCoverage: true, + coverageDirectory: "/coverage", + coverageReporters: ["json", "lcov", "text", "clover"], + coverageThreshold: { + global: { + branches: 75, + functions: 75, + lines: 80, + statements: 80, + }, + }, }; module.exports = createJestConfig(customJestConfig); diff --git a/package.json b/package.json index 9f2c8e1..bd4d469 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "dev": "next dev", + "test": "node ./node_modules/jest/bin/jest.js --coverage", "build": "next build", "start": "next start", "lint": "prettier --check .", diff --git a/src/app/hooks/__tests__/test-utils.ts b/src/app/hooks/__tests__/test-utils.ts new file mode 100644 index 0000000..928d112 --- /dev/null +++ b/src/app/hooks/__tests__/test-utils.ts @@ -0,0 +1,27 @@ +// test-utils.ts +import React from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, RenderHookResult } from "@testing-library/react-hooks"; + +/** + * Render a hook within a QueryClientProvider for testing. + * Returns the render result and the client for further manipulation. + */ +export function renderHookWithClient( + hook: (props: TProps) => TResult, + { initialProps }: { initialProps?: TProps } = {} +): { result: RenderHookResult; client: QueryClient } { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: Infinity, cacheTime: Infinity }, + mutations: { retry: false }, + }, + }); + + const wrapper: React.FC<{ children?: React.ReactNode }> = ({ children }) => ( + {children} + ); + + const result = renderHook(hook, { wrapper, initialProps }); + return { result, client }; +} diff --git a/src/app/hooks/__tests__/useApi.test.ts b/src/app/hooks/__tests__/useApi.test.ts new file mode 100644 index 0000000..9563b81 --- /dev/null +++ b/src/app/hooks/__tests__/useApi.test.ts @@ -0,0 +1,23 @@ +// Test for useApi hook +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useApi } from '../../hooks/useApi'; + +const wrapper = ({ children }: { children: React.ReactNode }) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return {children}; +}; + +test('fetches data successfully', async () => { + // Mock fetch + global.fetch = jest.fn(() => + Promise.resolve({ ok: true, json: () => Promise.resolve({ message: 'ok' }) } as any) + ) as any; + + const { result } = renderHook(() => useApi('/test'), { wrapper }); + await waitFor(() => result.current.isSuccess); + expect(result.current.data).toEqual({ message: 'ok' }); + expect(fetch).toHaveBeenCalledWith('http://localhost:3001/test', expect.any(Object)); +}); diff --git a/src/app/hooks/__tests__/useConfirmedMutation.test.ts b/src/app/hooks/__tests__/useConfirmedMutation.test.ts new file mode 100644 index 0000000..5b89f95 --- /dev/null +++ b/src/app/hooks/__tests__/useConfirmedMutation.test.ts @@ -0,0 +1,33 @@ +// src/app/hooks/__tests__/useConfirmedMutation.test.ts +import { renderHook, act } from "@testing-library/react-hooks"; +import { useConfirmedMutation } from "../useConfirmedMutation"; + +const mockAction = jest.fn(() => Promise.resolve()); + +test("trigger opens dialog with summary and confirm calls action", async () => { + const buildSummary = jest.fn(() => [{ label: "Amount", value: "100" }]); + const { result } = renderHook(() => + useConfirmedMutation(mockAction, { title: "Confirm", buildSummary }) + ); + + // Initially closed + expect(result.current.dialogProps.isOpen).toBe(false); + + // Trigger with variables + act(() => { + result.current.trigger({ amount: 100 }); + }); + + // Dialog should be open and summary set + expect(result.current.dialogProps.isOpen).toBe(true); + expect(buildSummary).toHaveBeenCalledWith({ amount: 100 }); + expect(result.current.dialogProps.summary).toEqual([{ label: "Amount", value: "100" }]); + + // Confirm should call the action and close dialog + await act(async () => { + await result.current.dialogProps.onConfirm(); + }); + + expect(mockAction).toHaveBeenCalledWith({ amount: 100 }); + expect(result.current.dialogProps.isOpen).toBe(false); +}); diff --git a/src/app/hooks/__tests__/useContractMutation.test.ts b/src/app/hooks/__tests__/useContractMutation.test.ts new file mode 100644 index 0000000..a9ba314 --- /dev/null +++ b/src/app/hooks/__tests__/useContractMutation.test.ts @@ -0,0 +1,102 @@ +import { renderHook, act } from " @testing-library/react-hooks\; +import { QueryClient, QueryClientProvider } from \@tanstack/react-query\; +import { useContractMutation } from \../useContractMutation\; + +// Mock toast and gamification store +jest.mock(\../useContractToast\, () => ({ + useContractToast: () => ({ + showPending: jest.fn().mockReturnValue(\toast-id\), + showSuccess: jest.fn(), + showError: jest.fn(), + }), +})); + +jest.mock(\../../stores/useGamificationStore\, () => ({ + useGamificationStore: () => ({ + addXP: jest.fn(), + unlockAchievement: jest.fn(), + }), +})); + +type MockMutationResult = { + mutate: jest.Mock, + mutateAsync: jest.Mock, + isLoading: boolean, + isError: boolean, + isSuccess: boolean, +}; + +const createMockMutation = ( + onSuccess?: (data: TData, variables: TVariables) => void, + onError?: (error: TError, variables: TVariables) => void, +): MockMutationResult => { + return { + mutate: jest.fn((variables: TVariables, opts: any) => { + if (onSuccess) { + const data = { txHash: \mock-hash\ } as unknown as TData; + opts?.onSuccess?.(data, variables); + onSuccess(data, variables); + } else if (onError) { + const err = new Error(\mock error\) as unknown as TError; + opts?.onError?.(err, variables); + onError(err, variables); + } + }), + mutateAsync: jest.fn(async (variables: TVariables) => { + if (onSuccess) { + const data = { txHash: \mock-hash\ } as unknown as TData; + onSuccess(data, variables); + return data; + } + throw new Error(\mock error\); + }), + isLoading: false, + isError: false, + isSuccess: false, + } as any; +}; + +const queryClient = new QueryClient(); +function wrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +describe(\useContractMutation\, () => { + it(\calls toast and gamification on successful mutate\, () => { + const mockMutation = createMockMutation<{ txHash: string }, Error, { amount: number }>( + () => {} + ); + const { result } = renderHook( + () => useContractMutation(mockMutation as any, { pendingMessage: \Pending\, successMessage: \Success\, gamificationXP: 10 }), + { wrapper }, + ); + act(() => { + result.current.mutate({ amount: 100 }); + }); + const { useContractToast } = require(\../useContractToast\); + const toast = useContractToast(); + expect(toast.showPending).toHaveBeenCalledWith(\Pending\); + expect(toast.showSuccess).toHaveBeenCalledWith(\toast-id\, expect.objectContaining({ successMessage: \Success\, txHash: \mock-hash\, network: \testnet\ })); + const { useGamificationStore } = require(\../../stores/useGamificationStore\); + const gamify = useGamificationStore(); + expect(gamify.addXP).toHaveBeenCalledWith(10, undefined); + }); + + it(\shows error toast on failed mutateAsync\, async () => { + const mockMutation = createMockMutation<{ txHash: string }, Error, { amount: number }>( + undefined, + () => {} + ); + const { result } = renderHook( + () => useContractMutation(mockMutation as any, { errorMessage: \Oops\ }), + { wrapper }, + ); + await act(async () => { + await expect(result.current.mutateAsync({ amount: 50 })).rejects.toThrow(); + }); + const { useContractToast } = require(\../useContractToast\); + const toast = useContractToast(); + expect(toast.showPending).toHaveBeenCalled(); + expect(toast.showError).toHaveBeenCalledWith(\toast-id\, expect.objectContaining({ errorMessage: \Oops\ })); + }); +}); diff --git a/src/app/hooks/__tests__/useRepaymentOperation.test.ts b/src/app/hooks/__tests__/useRepaymentOperation.test.ts new file mode 100644 index 0000000..8eb339b --- /dev/null +++ b/src/app/hooks/__tests__/useRepaymentOperation.test.ts @@ -0,0 +1,94 @@ +// src/app/hooks/__tests__/useRepaymentOperation.test.ts +import { renderHook, act } from "@testing-library/react-hooks"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useRepaymentOperation } from "../useRepaymentOperation"; + +// Mock the transaction hook +jest.mock("../useOptimisticUI", () => ({ + useTransaction: jest.fn(() => ({ + start: jest.fn(), + updateProgress: jest.fn(), + sign: jest.fn(), + submit: jest.fn(), + confirm: jest.fn(), + complete: jest.fn(), + fail: jest.fn(), + isOpen: false, + isLoading: false, + isSigning: false, + isSubmitted: false, + isConfirming: false, + isSuccess: false, + isError: false, + data: undefined, + })), +})); + +// Mock react-query's QueryClient +const createTestQueryClient = () => new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, +}); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe("useRepaymentOperation", () => { + it("executes repayment successfully", async () => { + const { result } = renderHook(() => useRepaymentOperation(), { wrapper }); + const repayment = result.current; + const promise = repayment.executeRepayment({ + loanId: 1, + amount: 100, + borrowerAddress: "GABC123", + }); + await act(async () => { + await promise; + }); + // Verify transaction flow calls + expect(repayment.start).toHaveBeenCalled(); + expect(repayment.updateProgress).toHaveBeenCalledWith(20, "Building transaction..."); + expect(repayment.sign).toHaveBeenCalled(); + expect(repayment.submit).toHaveBeenCalled(); + expect(repayment.confirm).toHaveBeenCalled(); + expect(repayment.complete).toHaveBeenCalled(); + expect(repayment.error).toBeNull(); + }); + + it("handles repayment error", async () => { + // Mock transaction.fail to set error state + const { useTransaction } = require("../useOptimisticUI"); + const failMock = jest.fn(); + useTransaction.mockReturnValue({ + start: jest.fn(), + updateProgress: jest.fn(), + sign: jest.fn(), + submit: jest.fn(), + confirm: jest.fn(), + complete: jest.fn(), + fail: failMock, + isOpen: false, + isLoading: false, + isSigning: false, + isSubmitted: false, + isConfirming: false, + isSuccess: false, + isError: false, + data: undefined, + }); + + const { result } = renderHook(() => useRepaymentOperation(), { wrapper }); + const repayment = result.current; + // Force error by making setTimeout reject + jest.spyOn(global, "setTimeout").mockImplementationOnce((_cb, _ms) => { + throw new Error("Simulated failure"); + }); + await expect( + act(async () => { + await repayment.executeRepayment({ loanId: 1, amount: 100, borrowerAddress: "GXYZ" }); + }) + ).rejects.toThrow(); + expect(failMock).toHaveBeenCalled(); + expect(repayment.error).not.toBeNull(); + }); +}); diff --git a/src/app/hooks/__tests__/useTransactionPreview.test.ts b/src/app/hooks/__tests__/useTransactionPreview.test.ts new file mode 100644 index 0000000..83fe42e --- /dev/null +++ b/src/app/hooks/__tests__/useTransactionPreview.test.ts @@ -0,0 +1,40 @@ +import { renderHook, act } from " @testing-library/react-hooks\; +import { QueryClient, QueryClientProvider } from \@tanstack/react-query\; +import { useTransactionPreview } from \../useTransactionPreview\; + +type TransactionPreviewData = { + operations: any[]; + balanceChanges: any[]; + estimatedGasFee: string; + network: string; +}; + +function wrapper({ children }: { children: React.ReactNode }) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return {children}; +} + +describe(\useTransactionPreview hook\, () => { + it(\opens modal and confirms callback\, async () => { + const onConfirm = jest.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useTransactionPreview(), { wrapper }); + const dummyData: TransactionPreviewData = { + operations: [], + balanceChanges: [], + estimatedGasFee: \0\, + network: \Testnet\, + } as any; + act(() => { + result.current.show(dummyData, onConfirm); + }); + expect(result.current.isOpen).toBe(true); + expect(result.current.data).toBe(dummyData); + await act(async () => { + await result.current.confirm(); + }); + expect(onConfirm).toHaveBeenCalled(); + expect(result.current.isOpen).toBe(false); + }); +}); diff --git a/src/app/stores/__tests__/useWalletStore.test.ts b/src/app/stores/__tests__/useWalletStore.test.ts new file mode 100644 index 0000000..142881c --- /dev/null +++ b/src/app/stores/__tests__/useWalletStore.test.ts @@ -0,0 +1,66 @@ +// src/app/stores/__tests__/useWalletStore.test.ts +import { useWalletStore } from "../useWalletStore"; + +describe("useWalletStore", () => { + beforeEach(() => { + // Reset store to initial state + useWalletStore.getState().disconnect(); + }); + + it("initial state is disconnected", () => { + const state = useWalletStore.getState(); + expect(state.status).toBe("disconnected"); + expect(state.address).toBeNull(); + expect(state.network).toBeNull(); + expect(state.balances).toEqual([]); + expect(state.isLoadingBalances).toBe(false); + expect(state.error).toBeNull(); + expect(state.errorKind).toBeNull(); + }); + + it("setConnected updates state correctly", () => { + const network = { chainId: 1, name: "Testnet", isSupported: true }; + useWalletStore.getState().setConnected("0xABC", network); + const state = useWalletStore.getState(); + expect(state.status).toBe("connected"); + expect(state.address).toBe("0xABC"); + expect(state.network).toBe(network); + expect(state.shouldAutoReconnect).toBe(true); + expect(state.error).toBeNull(); + }); + + it("disconnect resets to initial state", () => { + const network = { chainId: 1, name: "Testnet", isSupported: true }; + useWalletStore.getState().setConnected("0xABC", network); + // Disconnect + useWalletStore.getState().disconnect(); + const state = useWalletStore.getState(); + expect(state).toMatchObject({ + status: "disconnected", + address: null, + network: null, + balances: [], + isLoadingBalances: false, + error: null, + errorKind: null, + networkMismatch: false, + shouldAutoReconnect: false, + }); + }); + + it("setError records error and status", () => { + useWalletStore.getState().setError("Something went wrong", "error", "generic"); + const state = useWalletStore.getState(); + expect(state.error).toBe("Something went wrong"); + expect(state.status).toBe("error"); + expect(state.errorKind).toBe("generic"); + expect(state.isLoadingBalances).toBe(false); + }); + + it("setNetworkMismatch updates flag", () => { + useWalletStore.getState().setNetworkMismatch(true); + expect(useWalletStore.getState().networkMismatch).toBe(true); + useWalletStore.getState().setNetworkMismatch(false); + expect(useWalletStore.getState().networkMismatch).toBe(false); + }); +});