Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a764b97
test(usePlatformFee): add scaffold and on-chain fee success case
0x860 May 29, 2026
c5eefe4
test(usePlatformFee): verify 300 bps fallback when getter throws
0x860 May 29, 2026
36359eb
test(usePlatformFee): assert isFallback when on-chain data is absent
0x860 May 29, 2026
8521ed2
test(usePlatformFee): cover loading state during fee fetch
0x860 May 29, 2026
d446b36
test(usePlatformFee): verify fee and net preview math from bps
0x860 May 29, 2026
5d0f8c1
feat(useCampaign): expose notFound for missing campaign ids
0x860 May 29, 2026
edae5f9
test(useCampaign): add scaffold and loading-to-success transition
0x860 May 29, 2026
15c0b86
test(useCampaign): cover loading-to-error transition
0x860 May 29, 2026
b0741aa
test(useCampaign): assert notFound for unknown ids
0x860 May 29, 2026
e5b3dda
test(useCampaign): verify refetch invalidates and re-queries
0x860 May 29, 2026
ed2b84b
test(useCampaigns): cover loading-to-success list fetch
0x860 May 29, 2026
c3230a6
test(useCampaigns): cover loading-to-error transition
0x860 May 29, 2026
2edd274
test(useCampaigns): verify refetch updates listing state
0x860 May 29, 2026
e9b3f4b
test(WithdrawFunds): add scaffold and creator-only visibility
0x860 May 29, 2026
d9e4df0
test(WithdrawFunds): verify fee and net breakdown display
0x860 May 29, 2026
d1e26fd
test(WithdrawFunds): guard withdrawal before goal is reached
0x860 May 29, 2026
5eec268
test(WithdrawFunds): cover FundsAlreadyWithdrawn disabled state
0x860 May 29, 2026
5d576d7
test(WithdrawFunds): verify 3% default fee fallback in breakdown
0x860 May 29, 2026
27efd86
feat(DonationModal): show platform fee explanation to contributors
0x860 May 29, 2026
3d80f9a
test(DonationModal): add scaffold and zero-amount validation
0x860 May 29, 2026
2fffa8c
test(DonationModal): reject negative and non-numeric amounts
0x860 May 29, 2026
4331aeb
test(DonationModal): render platform fee and net explanation
0x860 May 29, 2026
5b09ee9
test(DonationModal): verify contribute receives stroop amount
0x860 May 29, 2026
1c5b85b
test(DonationModal): disable submit UI while transaction is pending
0x860 May 29, 2026
e589867
test: align assertions with split DOM text and query retries
0x860 May 29, 2026
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
138 changes: 138 additions & 0 deletions src/__tests__/components/DonationModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import DonationModal from "@/components/DonationModal";
import { Category, type Campaign } from "@/types";

jest.mock("@/lib/contractClient", () => ({
contribute: jest.fn(),
}));

jest.mock("@/components/ToastProvider", () => ({
useToast: () => ({
showError: jest.fn(),
}),
}));

jest.mock("@/components/WalletContext", () => ({
useWallet: () => ({
publicKey: "GCONTRIB1111111111111111111111111111111111111111111111111",
}),
}));

jest.mock("@/hooks/usePlatformFee", () => ({
usePlatformFee: () => ({
platformFeeBps: 300,
isLoading: false,
isFallback: false,
}),
}));

jest.mock("@/lib/analytics", () => ({
trackClickContribute: jest.fn(),
trackEnterAmount: jest.fn(),
trackReviewContribution: jest.fn(),
trackSignTransaction: jest.fn(),
trackContributionConfirmed: jest.fn(),
trackContributionError: jest.fn(),
}));

import { contribute } from "@/lib/contractClient";

const mockContribute = contribute as jest.MockedFunction<typeof contribute>;

const CREATOR = "GCREATOR1111111111111111111111111111111111111111111111111";
const CONTRIBUTOR = "GCONTRIB1111111111111111111111111111111111111111111111111";

function makeCampaign(overrides: Partial<Campaign> = {}): Campaign {
return {
id: 1,
creator: CREATOR,
title: "Help Build a School",
description: "Desc",
created_at: 1,
status: "active",
funding_goal: BigInt(100_000_000),
deadline: 9_999_999_999,
amount_raised: BigInt(10_000_000),
is_active: true,
funds_withdrawn: false,
is_cancelled: false,
is_verified: true,
category: Category.Educator,
has_revenue_sharing: false,
revenue_share_percentage: 0,
...overrides,
};
}

const defaultProps = {
campaign: makeCampaign(),
onClose: jest.fn(),
onSuccess: jest.fn(),
};

describe("DonationModal", () => {
beforeEach(() => {
jest.clearAllMocks();
});

it("rejects zero amounts by disabling submit", () => {
render(<DonationModal {...defaultProps} />);

const input = screen.getByLabelText("Amount (XLM)");
fireEvent.change(input, { target: { value: "0" } });

expect(screen.getByRole("button", { name: /Donate/ })).toBeDisabled();
});

it("rejects negative and non-numeric amounts", () => {
render(<DonationModal {...defaultProps} />);

const input = screen.getByLabelText("Amount (XLM)");
const button = screen.getByRole("button", { name: /Donate/ });

fireEvent.change(input, { target: { value: "-5" } });
expect(button).toBeDisabled();

fireEvent.change(input, { target: { value: "abc" } });
expect(button).toBeDisabled();
});

it("renders the platform fee explanation", () => {
render(<DonationModal {...defaultProps} />);

expect(
screen.getByText(/A platform fee of 3% is deducted from funds when withdrawn by the creator/),
).toBeInTheDocument();
});

it("calls contribute with amount converted to stroops", async () => {
mockContribute.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve("mock-tx-hash"), 50)),
);

render(<DonationModal {...defaultProps} />);

fireEvent.change(screen.getByLabelText("Amount (XLM)"), { target: { value: "10" } });
fireEvent.click(screen.getByRole("button", { name: /Donate 10 XLM/ }));

await waitFor(() =>
expect(mockContribute).toHaveBeenCalledWith(1, CONTRIBUTOR, BigInt(100_000_000), {
onStatus: expect.any(Function),
}),
);
});

it("hides submit controls while a transaction is in flight", async () => {
mockContribute.mockImplementation(() => new Promise(() => {}));

render(<DonationModal {...defaultProps} />);

fireEvent.change(screen.getByLabelText("Amount (XLM)"), { target: { value: "5" } });
fireEvent.click(screen.getByRole("button", { name: /Donate 5 XLM/ }));

await waitFor(() => {
expect(screen.queryByRole("button", { name: /Donate/ })).not.toBeInTheDocument();
});
expect(screen.getByText(/Submitting transaction to the network/)).toBeInTheDocument();
});
});
121 changes: 121 additions & 0 deletions src/__tests__/components/WithdrawFunds.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { render, screen } from "@testing-library/react";
import WithdrawFunds from "@/components/WithdrawFunds";
import { Category, type Campaign } from "@/types";

jest.mock("@/lib/contractClient", () => ({
withdrawFunds: jest.fn(),
}));

jest.mock("@/components/ToastProvider", () => ({
useToast: () => ({
showError: jest.fn(),
showSuccess: jest.fn(),
}),
}));

jest.mock("@/hooks/useWriteGuard", () => ({
useWriteGuard: () => ({
invoke: jest.fn(),
isPending: () => false,
}),
}));

const CREATOR = "GCREATOR1111111111111111111111111111111111111111111111111";

function makeCampaign(overrides: Partial<Campaign> = {}): Campaign {
return {
id: 1,
creator: CREATOR,
title: "Test Campaign",
description: "Desc",
created_at: 1,
status: "active",
funding_goal: BigInt(100_000_000),
deadline: Math.floor(Date.now() / 1000) - 3600,
amount_raised: BigInt(100_000_000),
is_active: false,
funds_withdrawn: false,
is_cancelled: false,
is_verified: true,
category: Category.Educator,
has_revenue_sharing: false,
revenue_share_percentage: 0,
...overrides,
};
}

describe("WithdrawFunds", () => {
it("renders nothing for non-creator wallets", () => {
const { container } = render(
<WithdrawFunds
campaign={makeCampaign()}
userWalletAddress="GOTHER111111111111111111111111111111111111111111111111111"
/>,
);

expect(container).toBeEmptyDOMElement();
});

it("shows fee and net breakdown when goal is reached", () => {
render(
<WithdrawFunds
campaign={makeCampaign({ amount_raised: BigInt(100_000_000) })}
userWalletAddress={CREATOR}
platformFeeBps={300}
/>,
);

expect(screen.getByText("Total raised")).toBeInTheDocument();
expect(
screen.getByText((_content, element) =>
element?.tagName === "SPAN" ? element.textContent?.startsWith("Platform fee") ?? false : false,
),
).toBeInTheDocument();
expect(screen.getByText("You will receive")).toBeInTheDocument();
expect(screen.getByText("-0.3 XLM")).toBeInTheDocument();
expect(screen.getByText("9.7 XLM")).toBeInTheDocument();
});

it("blocks withdrawal when funding goal has not been reached", () => {
render(
<WithdrawFunds
campaign={makeCampaign({
amount_raised: BigInt(50_000_000),
funding_goal: BigInt(100_000_000),
})}
userWalletAddress={CREATOR}
/>,
);

expect(screen.getByRole("button", { name: "Withdraw Funds" })).toBeDisabled();
expect(screen.getByText("Funding goal has not been reached")).toBeInTheDocument();
});

it("handles already-withdrawn state", () => {
render(
<WithdrawFunds
campaign={makeCampaign({ funds_withdrawn: true })}
userWalletAddress={CREATOR}
/>,
);

expect(screen.getByRole("button", { name: "Withdraw Funds" })).toBeDisabled();
expect(screen.getByText("Funds have already been withdrawn")).toBeInTheDocument();
});

it("defaults to 300 bps platform fee when prop is omitted", () => {
render(
<WithdrawFunds
campaign={makeCampaign({ amount_raised: BigInt(100_000_000) })}
userWalletAddress={CREATOR}
/>,
);

expect(
screen.getByText((_content, element) =>
element?.tagName === "SPAN" ? element.textContent?.startsWith("Platform fee") ?? false : false,
),
).toBeInTheDocument();
expect(screen.getByText("-0.3 XLM")).toBeInTheDocument();
});
});
110 changes: 110 additions & 0 deletions src/__tests__/hooks/useCampaign.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { renderHook, waitFor, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { useCampaign } from "@/hooks/useCampaign";
import { Category, type Campaign } from "@/types";

jest.mock("@/lib/contractClient", () => ({
getCampaign: jest.fn(),
}));

jest.mock("@/hooks/useWindowVisibility", () => ({
useWindowVisibility: () => true,
}));

import { getCampaign } from "@/lib/contractClient";

const mockGetCampaign = getCampaign as jest.MockedFunction<typeof getCampaign>;

function makeCampaign(overrides: Partial<Campaign> = {}): Campaign {
return {
id: 1,
creator: "GCREATOR1111111111111111111111111111111111111111111111111",
title: "Test Campaign",
description: "Desc",
created_at: 1,
status: "active",
funding_goal: BigInt(100_000_000),
deadline: 9_999_999_999,
amount_raised: BigInt(10_000_000),
is_active: true,
funds_withdrawn: false,
is_cancelled: false,
is_verified: true,
category: Category.Educator,
has_revenue_sharing: false,
revenue_share_percentage: 0,
...overrides,
};
}

function createWrapper() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
}

describe("useCampaign", () => {
beforeEach(() => {
jest.clearAllMocks();
});

it("transitions from loading to success with campaign data", async () => {
mockGetCampaign.mockResolvedValue(makeCampaign());

const { result } = renderHook(() => useCampaign(1), { wrapper: createWrapper() });

expect(result.current.isLoading).toBe(true);
expect(result.current.campaign).toBeNull();

await waitFor(() => expect(result.current.isLoading).toBe(false));

expect(result.current.campaign?.title).toBe("Test Campaign");
expect(result.current.error).toBeNull();
expect(result.current.notFound).toBe(false);
});

it("transitions from loading to error when getCampaign throws", async () => {
mockGetCampaign.mockRejectedValue(new Error("rpc unavailable"));

const { result } = renderHook(() => useCampaign(1), { wrapper: createWrapper() });

await waitFor(() => expect(result.current.isLoading).toBe(false));

expect(result.current.campaign).toBeNull();
expect(result.current.error).toBe("rpc unavailable");
expect(result.current.notFound).toBe(false);
});

it("returns notFound for unknown campaign ids", async () => {
mockGetCampaign.mockResolvedValue(null);

const { result } = renderHook(() => useCampaign(999), { wrapper: createWrapper() });

await waitFor(() => expect(result.current.isLoading).toBe(false));

expect(result.current.campaign).toBeNull();
expect(result.current.notFound).toBe(true);
expect(result.current.error).toBeNull();
});

it("refetch re-queries and updates campaign state", async () => {
mockGetCampaign
.mockResolvedValueOnce(makeCampaign({ title: "First fetch" }))
.mockResolvedValueOnce(makeCampaign({ title: "After refetch" }));

const { result } = renderHook(() => useCampaign(1), { wrapper: createWrapper() });

await waitFor(() => expect(result.current.campaign?.title).toBe("First fetch"));

await act(async () => {
result.current.refetch();
});

await waitFor(() => expect(result.current.campaign?.title).toBe("After refetch"));
expect(mockGetCampaign).toHaveBeenCalledTimes(2);
});
});
Loading
Loading