Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,22 @@ const createJestConfig = nextJest({

const customJestConfig = {
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
testEnvironment: "jest-environment-jsdom",
transform: { '^.+\\\\.(ts|tsx)$': 'ts-jest' },
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
testPathIgnorePatterns: ["<rootDir>/e2e/", "<rootDir>/node_modules/"],
collectCoverage: true,
coverageDirectory: "<rootDir>/coverage",
coverageReporters: ["json", "lcov", "text", "clover"],
coverageThreshold: {
global: {
branches: 75,
functions: 75,
lines: 80,
statements: 80,
},
},
};

module.exports = createJestConfig(customJestConfig);
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down
27 changes: 27 additions & 0 deletions src/app/hooks/__tests__/test-utils.ts
Original file line number Diff line number Diff line change
@@ -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<TProps, TResult>(
hook: (props: TProps) => TResult,
{ initialProps }: { initialProps?: TProps } = {}
): { result: RenderHookResult<TProps, TResult>; 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 }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);

const result = renderHook<TProps, TResult>(hook, { wrapper, initialProps });
return { result, client };
}
23 changes: 23 additions & 0 deletions src/app/hooks/__tests__/useApi.test.ts
Original file line number Diff line number Diff line change
@@ -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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};

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));
});
33 changes: 33 additions & 0 deletions src/app/hooks/__tests__/useConfirmedMutation.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
102 changes: 102 additions & 0 deletions src/app/hooks/__tests__/useContractMutation.test.ts
Original file line number Diff line number Diff line change
@@ -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<TData, TError, TVariables> = {
mutate: jest.Mock,
mutateAsync: jest.Mock,
isLoading: boolean,
isError: boolean,
isSuccess: boolean,
};

const createMockMutation = <TData, TError, TVariables>(
onSuccess?: (data: TData, variables: TVariables) => void,
onError?: (error: TError, variables: TVariables) => void,
): MockMutationResult<TData, TError, TVariables> => {
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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

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\ }));
});
});
94 changes: 94 additions & 0 deletions src/app/hooks/__tests__/useRepaymentOperation.test.ts
Original file line number Diff line number Diff line change
@@ -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 }) => (
<QueryClientProvider client={createTestQueryClient()}>{children}</QueryClientProvider>
);

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();
});
});
40 changes: 40 additions & 0 deletions src/app/hooks/__tests__/useTransactionPreview.test.ts
Original file line number Diff line number Diff line change
@@ -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 <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

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);
});
});
Loading