diff --git a/src/__tests__/components/DonationModal.test.tsx b/src/__tests__/components/DonationModal.test.tsx new file mode 100644 index 0000000..075ea8c --- /dev/null +++ b/src/__tests__/components/DonationModal.test.tsx @@ -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; + +const CREATOR = "GCREATOR1111111111111111111111111111111111111111111111111"; +const CONTRIBUTOR = "GCONTRIB1111111111111111111111111111111111111111111111111"; + +function makeCampaign(overrides: Partial = {}): 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/src/__tests__/components/WithdrawFunds.test.tsx b/src/__tests__/components/WithdrawFunds.test.tsx new file mode 100644 index 0000000..0b1ff9d --- /dev/null +++ b/src/__tests__/components/WithdrawFunds.test.tsx @@ -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 { + 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( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it("shows fee and net breakdown when goal is reached", () => { + render( + , + ); + + 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( + , + ); + + expect(screen.getByRole("button", { name: "Withdraw Funds" })).toBeDisabled(); + expect(screen.getByText("Funding goal has not been reached")).toBeInTheDocument(); + }); + + it("handles already-withdrawn state", () => { + render( + , + ); + + 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( + , + ); + + expect( + screen.getByText((_content, element) => + element?.tagName === "SPAN" ? element.textContent?.startsWith("Platform fee") ?? false : false, + ), + ).toBeInTheDocument(); + expect(screen.getByText("-0.3 XLM")).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/hooks/useCampaign.test.tsx b/src/__tests__/hooks/useCampaign.test.tsx new file mode 100644 index 0000000..e17d70a --- /dev/null +++ b/src/__tests__/hooks/useCampaign.test.tsx @@ -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; + +function makeCampaign(overrides: Partial = {}): 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 {children}; + }; +} + +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); + }); +}); diff --git a/src/__tests__/hooks/useCampaigns.test.tsx b/src/__tests__/hooks/useCampaigns.test.tsx new file mode 100644 index 0000000..bf6fe25 --- /dev/null +++ b/src/__tests__/hooks/useCampaigns.test.tsx @@ -0,0 +1,95 @@ +import { renderHook, waitFor, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { useCampaigns } from "@/hooks/useCampaigns"; +import { Category, type Campaign } from "@/types"; + +jest.mock("@/lib/contractClient", () => ({ + getAllCampaigns: jest.fn(), +})); + +jest.mock("@/hooks/useWindowVisibility", () => ({ + useWindowVisibility: () => true, +})); + +import { getAllCampaigns } from "@/lib/contractClient"; + +const mockGetAllCampaigns = getAllCampaigns as jest.MockedFunction; + +function makeCampaign(id: number): Campaign { + return { + id, + creator: "GCREATOR1111111111111111111111111111111111111111111111111", + title: `Campaign ${id}`, + 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, + }; +} + +function createWrapper() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("useCampaigns", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("transitions from loading to success with campaign list", async () => { + mockGetAllCampaigns.mockResolvedValue([makeCampaign(1), makeCampaign(2)]); + + const { result } = renderHook(() => useCampaigns(), { wrapper: createWrapper() }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.campaigns).toEqual([]); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.campaigns).toHaveLength(2); + expect(result.current.error).toBeNull(); + }); + + it("transitions from loading to error when getAllCampaigns throws", async () => { + mockGetAllCampaigns.mockRejectedValue(new Error("network failure")); + + const { result } = renderHook(() => useCampaigns(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.campaigns).toEqual([]); + expect(result.current.error).toBe("network failure"); + }); + + it("refetch re-queries and updates the campaign list", async () => { + mockGetAllCampaigns + .mockResolvedValueOnce([makeCampaign(1)]) + .mockResolvedValueOnce([makeCampaign(1), makeCampaign(2)]); + + const { result } = renderHook(() => useCampaigns(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.campaigns).toHaveLength(1)); + + await act(async () => { + result.current.refetch(); + }); + + await waitFor(() => expect(result.current.campaigns).toHaveLength(2)); + expect(mockGetAllCampaigns).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/__tests__/hooks/usePlatformFee.test.tsx b/src/__tests__/hooks/usePlatformFee.test.tsx new file mode 100644 index 0000000..0629259 --- /dev/null +++ b/src/__tests__/hooks/usePlatformFee.test.tsx @@ -0,0 +1,82 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { usePlatformFee, DEFAULT_PLATFORM_FEE_BPS } from "@/hooks/usePlatformFee"; + +jest.mock("@/lib/contractClient", () => ({ + getPlatformFee: jest.fn(), +})); + +import { getPlatformFee } from "@/lib/contractClient"; + +const mockGetPlatformFee = getPlatformFee as jest.MockedFunction; + +function createWrapper() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("usePlatformFee", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns on-chain fee when getPlatformFee succeeds", async () => { + mockGetPlatformFee.mockResolvedValue(250); + + const { result } = renderHook(() => usePlatformFee(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.platformFeeBps).toBe(250); + expect(result.current.isFallback).toBe(false); + }); + + it("falls back to 300 bps when getPlatformFee throws", async () => { + mockGetPlatformFee.mockRejectedValue(new Error("getter unavailable")); + + const { result } = renderHook(() => usePlatformFee(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isFallback).toBe(true)); + + expect(result.current.platformFeeBps).toBe(DEFAULT_PLATFORM_FEE_BPS); + }); + + it("marks isFallback true while data is still undefined after error", async () => { + mockGetPlatformFee.mockRejectedValue(new Error("rpc down")); + + const { result } = renderHook(() => usePlatformFee(), { wrapper: createWrapper() }); + + expect(result.current.platformFeeBps).toBe(DEFAULT_PLATFORM_FEE_BPS); + + await waitFor(() => expect(result.current.isFallback).toBe(true)); + }); + + it("exposes isLoading while the platform fee query is in flight", () => { + mockGetPlatformFee.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => usePlatformFee(), { wrapper: createWrapper() }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.platformFeeBps).toBe(DEFAULT_PLATFORM_FEE_BPS); + }); + + it("supports fee and net previews from platformFeeBps", async () => { + mockGetPlatformFee.mockResolvedValue(300); + + const { result } = renderHook(() => usePlatformFee(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const totalRaised = 100; + const feeAmount = totalRaised * (result.current.platformFeeBps / 10_000); + const creatorNet = totalRaised - feeAmount; + + expect(feeAmount).toBeCloseTo(3); + expect(creatorNet).toBeCloseTo(97); + }); +}); diff --git a/src/components/DonationModal.tsx b/src/components/DonationModal.tsx index 876c796..844d613 100644 --- a/src/components/DonationModal.tsx +++ b/src/components/DonationModal.tsx @@ -2,9 +2,10 @@ import { useState, useEffect, useRef } from "react"; import { contribute } from "../lib/contractClient"; -import { Campaign, xlmToStroops, formatStroopsAsXlm, calculateFundingPercentage } from "../types"; +import { Campaign, xlmToStroops, formatStroopsAsXlm, calculateFundingPercentage, basisPointsToPercentage } from "../types"; import { useToast } from "./ToastProvider"; import { useWallet } from "./WalletContext"; +import { usePlatformFee } from "../hooks/usePlatformFee"; import { parseContractError } from "../utils/contractErrors"; import { type TransactionLifecyclePhase } from "../lib/contractClient"; import { validateContributorNotCreator } from "../utils/validators"; @@ -29,6 +30,7 @@ type Step = "input" | "pending" | "confirmed"; export default function DonationModal({ campaign, onClose, onSuccess }: DonationModalProps) { const { publicKey } = useWallet(); const { showError } = useToast(); + const { platformFeeBps } = usePlatformFee(); const [amount, setAmount] = useState(""); const [step, setStep] = useState("input"); @@ -306,6 +308,11 @@ export default function DonationModal({ campaign, onClose, onSuccess }: Donation )} +

+ A platform fee of {basisPointsToPercentage(platformFeeBps)} is deducted from funds + when withdrawn by the creator. Your full donation goes toward the campaign total. +

+