From b2406e5104bc950222f20a92f5b933b673293265 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Mon, 29 Jun 2026 23:35:24 +0100 Subject: [PATCH] test: add bounty application mutation hook coverage Reset branch onto current main so this PR no longer deletes the work from PRs #298, #311, and #313. Only changes left: - New hooks/__tests__/use-bounty-application.test.tsx with 12 cases covering happy and rollback paths for useApplyToBounty, useSelectApplicant, useApproveApplicationSubmission, useRequestRevisions, useApplyForSlot, useReleasePayment, useRemoveContributor, useDeclineApplicant, and useRaiseDispute. - Validation in useApplyToBounty that throws ApplicationError when applicantAddress is missing. Production guard, not test-only. Verified pnpm lint, pnpm tsc --noEmit, pnpm build, and the new test suite (12/12 passing) all clean. --- .../__tests__/use-bounty-application.test.tsx | 461 ++++++++++++++++++ hooks/use-application-mutations.ts | 7 + 2 files changed, 468 insertions(+) create mode 100644 hooks/__tests__/use-bounty-application.test.tsx diff --git a/hooks/__tests__/use-bounty-application.test.tsx b/hooks/__tests__/use-bounty-application.test.tsx new file mode 100644 index 00000000..5723c560 --- /dev/null +++ b/hooks/__tests__/use-bounty-application.test.tsx @@ -0,0 +1,461 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { ApplicationError } from "../use-application-contracts"; +import { + useApplyToBounty, + useApproveApplicationSubmission, + useDeclineApplicant, + useSelectApplicant, +} from "../use-application-mutations"; +import { useRequestRevisions } from "../use-application-review-mutations"; +import { useRaiseDispute } from "../use-dispute-mutations"; +import { + useApplyForSlot, + useReleasePayment, + useRemoveContributor, +} from "../use-milestone-mutations"; +import { bountyKeys } from "@/lib/query/query-keys"; +import { escrowKeys } from "../use-escrow"; +import { authClient } from "@/lib/auth-client"; +import { fetcher } from "@/lib/graphql/client"; +import { post } from "@/lib/api/client"; +import { EscrowService } from "@/lib/services/escrow"; +import { DisputeReasonEnum } from "@/lib/graphql/generated"; + +jest.mock("@/lib/auth-client", () => ({ + authClient: { + useSession: jest.fn(), + }, +})); + +jest.mock("@/lib/graphql/client", () => ({ + fetcher: jest.fn(), +})); + +jest.mock("@/lib/api/client", () => ({ + post: jest.fn(), +})); + +jest.mock("@/lib/services/escrow", () => ({ + EscrowService: { + releasePayment: jest.fn(), + }, +})); + +type Harness = { + queryClient: QueryClient; + wrapper: ({ children }: { children: ReactNode }) => React.JSX.Element; +}; + +const bountyId = "123"; +const applicantAddress = "GAPPLICANT"; +const creatorAddress = "GCREATOR"; +const originalBounty = { + bounty: { + id: bountyId, + status: "OPEN", + updatedAt: "2025-01-01T00:00:00.000Z", + rewardAmount: 100, + milestones: [ + { id: "m1", title: "M1" }, + { id: "m2", title: "M2" }, + ], + totalSlotsOccupied: 1, + contributorProgress: [ + { userId: "user-1", userName: "Existing", currentMilestoneId: "m1" }, + ], + applications: [ + { id: "app-1", applicantAddress, status: "PENDING" }, + { id: "app-2", applicantAddress: "GOTHER", status: "PENDING" }, + ], + }, +}; + +function createHarness(): Harness { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return { queryClient, wrapper }; +} + +function seedBounty(queryClient: QueryClient, value = originalBounty) { + queryClient.setQueryData( + bountyKeys.detail(bountyId), + JSON.parse(JSON.stringify(value)), + ); +} + +function installApplicationContracts( + overrides: Partial> = {}, +) { + const contracts = { + apply: jest.fn().mockResolvedValue({ txHash: "tx-apply" }), + selectApplicant: jest.fn().mockResolvedValue({ txHash: "tx-select" }), + submitWork: jest.fn().mockResolvedValue({ txHash: "tx-submit" }), + approveSubmission: jest.fn().mockResolvedValue({ txHash: "tx-approve" }), + applyForSlot: jest.fn().mockResolvedValue({ txHash: "tx-slot" }), + ...overrides, + }; + ( + globalThis as typeof globalThis & { + __applicationContracts?: typeof contracts; + } + ).__applicationContracts = contracts; + return contracts; +} + +describe("bounty application mutation hooks", () => { + beforeEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + installApplicationContracts(); + (authClient.useSession as jest.Mock).mockReturnValue({ + data: { + user: { id: "user-2", name: "New Contributor", image: "avatar.png" }, + }, + }); + (fetcher as jest.Mock).mockReturnValue( + jest.fn().mockResolvedValue({ reviewSubmission: { id: "submission-1" } }), + ); + (post as jest.Mock).mockResolvedValue({ + id: "dispute-1", + campaignId: bountyId, + status: "OPEN", + }); + (EscrowService.releasePayment as jest.Mock).mockResolvedValue({ + poolId: bountyId, + }); + }); + + afterEach(() => { + delete (globalThis as { __applicationContracts?: unknown }) + .__applicationContracts; + jest.useRealTimers(); + }); + + it("useApplyToBounty calls the application contract", async () => { + const { wrapper } = createHarness(); + const contracts = installApplicationContracts(); + const { result } = renderHook(() => useApplyToBounty(), { wrapper }); + + await act(async () => { + await result.current.mutateAsync({ + bountyId, + applicantAddress, + proposal: "I can do it", + }); + }); + + expect(contracts.apply).toHaveBeenCalledWith({ + applicant: applicantAddress, + bountyId: BigInt(123), + proposal: "I can do it", + }); + }); + + it("useApplyToBounty rolls back/no-ops cached bounty data when the contract fails", async () => { + const { queryClient, wrapper } = createHarness(); + seedBounty(queryClient); + installApplicationContracts({ + apply: jest.fn().mockRejectedValue(new Error("contract failed")), + }); + const { result } = renderHook(() => useApplyToBounty(), { wrapper }); + + await expect( + result.current.mutateAsync({ + bountyId, + applicantAddress, + proposal: "I can do it", + }), + ).rejects.toThrow("contract failed"); + expect(queryClient.getQueryData(bountyKeys.detail(bountyId))).toEqual( + originalBounty, + ); + }); + + it("useApplyToBounty throws when the wallet address is missing", async () => { + const { wrapper } = createHarness(); + const contracts = installApplicationContracts(); + const { result } = renderHook(() => useApplyToBounty(), { wrapper }); + + await expect( + result.current.mutateAsync({ + bountyId, + applicantAddress: "", + proposal: "I can do it", + }), + ).rejects.toThrow(ApplicationError); + expect(contracts.apply).not.toHaveBeenCalled(); + }); + + it.each([ + [ + "useSelectApplicant", + () => useSelectApplicant(), + { bountyId, creatorAddress, applicantAddress }, + "IN_PROGRESS", + { selectApplicant: jest.fn().mockResolvedValue({ txHash: "tx" }) }, + ], + [ + "useApproveApplicationSubmission", + () => useApproveApplicationSubmission(), + { bountyId, creatorAddress, points: 10 }, + "COMPLETED", + { approveSubmission: jest.fn().mockResolvedValue({ txHash: "tx" }) }, + ], + ])( + "%s optimistically updates status and rolls back on error", + async (_name, useHook, variables, status, successOverride) => { + const { queryClient, wrapper } = createHarness(); + seedBounty(queryClient); + installApplicationContracts( + successOverride as Partial>, + ); + const { result } = renderHook( + useHook as () => ReturnType, + { wrapper }, + ); + + await act(async () => { + await result.current.mutateAsync(variables as never); + }); + expect( + ( + queryClient.getQueryData( + bountyKeys.detail(bountyId), + ) as typeof originalBounty + ).bounty.status, + ).toBe(status); + + seedBounty(queryClient); + installApplicationContracts( + Object.fromEntries( + Object.keys(successOverride as object).map((key) => [ + key, + jest.fn().mockRejectedValue(new Error("contract failed")), + ]), + ), + ); + const { result: errorResult } = renderHook( + useHook as () => ReturnType, + { wrapper }, + ); + await expect( + errorResult.current.mutateAsync(variables as never), + ).rejects.toThrow("contract failed"); + expect(queryClient.getQueryData(bountyKeys.detail(bountyId))).toEqual( + originalBounty, + ); + }, + ); + + it("useRequestRevisions updates to UNDER_REVIEW and rolls back on GraphQL error", async () => { + const { queryClient, wrapper } = createHarness(); + seedBounty(queryClient); + const { result } = renderHook(() => useRequestRevisions(), { wrapper }); + await act(async () => { + await result.current.mutateAsync({ + bountyId, + submissionId: "submission-1", + feedback: "Fix it", + }); + }); + expect( + ( + queryClient.getQueryData( + bountyKeys.detail(bountyId), + ) as typeof originalBounty + ).bounty.status, + ).toBe("UNDER_REVIEW"); + + seedBounty(queryClient); + (fetcher as jest.Mock).mockReturnValue( + jest.fn().mockRejectedValue(new Error("graphql failed")), + ); + const { result: errorResult } = renderHook(() => useRequestRevisions(), { + wrapper, + }); + await expect( + errorResult.current.mutateAsync({ + bountyId, + submissionId: "submission-1", + feedback: "Fix it", + }), + ).rejects.toThrow("graphql failed"); + expect(queryClient.getQueryData(bountyKeys.detail(bountyId))).toEqual( + originalBounty, + ); + }); + + it("useApplyForSlot increments slot count, adds contributor progress, and rolls back on error", async () => { + const { queryClient, wrapper } = createHarness(); + seedBounty(queryClient); + const { result } = renderHook(() => useApplyForSlot(), { wrapper }); + await act(async () => { + await result.current.mutateAsync({ bountyId, applicantAddress }); + }); + const data = queryClient.getQueryData( + bountyKeys.detail(bountyId), + ) as typeof originalBounty; + expect(data.bounty.totalSlotsOccupied).toBe(2); + expect(data.bounty.contributorProgress).toEqual( + expect.arrayContaining([expect.objectContaining({ userId: "user-2" })]), + ); + + seedBounty(queryClient); + installApplicationContracts({ + applyForSlot: jest.fn().mockRejectedValue(new Error("contract failed")), + }); + const { result: errorResult } = renderHook(() => useApplyForSlot(), { + wrapper, + }); + await expect( + errorResult.current.mutateAsync({ bountyId, applicantAddress }), + ).rejects.toThrow("contract failed"); + expect(queryClient.getQueryData(bountyKeys.detail(bountyId))).toEqual( + originalBounty, + ); + }); + + it("useReleasePayment increments the escrow released amount and rolls back on error", async () => { + const { queryClient, wrapper } = createHarness(); + seedBounty(queryClient); + const pool = { + poolId: bountyId, + totalAmount: 100, + releasedAmount: 20, + status: "Escrowed", + asset: "USDC", + isLocked: true, + expiry: null, + }; + queryClient.setQueryData(escrowKeys.pool(bountyId), pool); + const { result } = renderHook(() => useReleasePayment(bountyId), { + wrapper, + }); + await act(async () => { + await result.current.mutateAsync({ + contributorId: "user-1", + milestoneId: "m1", + }); + }); + expect(queryClient.getQueryData(escrowKeys.pool(bountyId))).toEqual( + expect.objectContaining({ + releasedAmount: 70, + status: "Partially Released", + }), + ); + + queryClient.setQueryData(escrowKeys.pool(bountyId), pool); + (EscrowService.releasePayment as jest.Mock).mockRejectedValue( + new Error("escrow failed"), + ); + const { result: errorResult } = renderHook( + () => useReleasePayment(bountyId), + { wrapper }, + ); + await expect( + errorResult.current.mutateAsync({ + contributorId: "user-1", + milestoneId: "m1", + }), + ).rejects.toThrow("escrow failed"); + expect(queryClient.getQueryData(escrowKeys.pool(bountyId))).toEqual(pool); + }); + + it("useRemoveContributor removes progress and decrements totalSlotsOccupied", async () => { + jest.useFakeTimers(); + const { queryClient, wrapper } = createHarness(); + seedBounty(queryClient); + const { result } = renderHook(() => useRemoveContributor(bountyId), { + wrapper, + }); + const promise = result.current.mutateAsync({ contributorId: "user-1" }); + await waitFor(() => + expect( + ( + queryClient.getQueryData( + bountyKeys.detail(bountyId), + ) as typeof originalBounty + ).bounty.totalSlotsOccupied, + ).toBe(0), + ); + expect( + ( + queryClient.getQueryData( + bountyKeys.detail(bountyId), + ) as typeof originalBounty + ).bounty.contributorProgress, + ).toEqual([]); + act(() => jest.advanceTimersByTime(1000)); + await act(async () => { + await promise; + }); + }); + + it("useDeclineApplicant removes the applicant from cached bounty data", async () => { + const { queryClient, wrapper } = createHarness(); + seedBounty(queryClient); + const { result } = renderHook(() => useDeclineApplicant(), { wrapper }); + await act(async () => { + await result.current.mutateAsync({ + bountyId, + applicantAddress, + reason: "No fit", + }); + }); + expect( + ( + queryClient.getQueryData( + bountyKeys.detail(bountyId), + ) as typeof originalBounty + ).bounty.applications, + ).toEqual([{ id: "app-2", applicantAddress: "GOTHER", status: "PENDING" }]); + }); + + it("useRaiseDispute posts to /api/disputes and invalidates bounty queries", async () => { + const { queryClient, wrapper } = createHarness(); + const invalidateSpy = jest.spyOn(queryClient, "invalidateQueries"); + const { result } = renderHook(() => useRaiseDispute(), { wrapper }); + await act(async () => { + await result.current.mutateAsync({ + bountyId, + reason: DisputeReasonEnum.Other, + description: "Need mediation", + }); + }); + expect(post).toHaveBeenCalledWith("/api/disputes", { + campaignId: bountyId, + reason: DisputeReasonEnum.Other, + description: "Need mediation", + }); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: bountyKeys.detail(bountyId), + }); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: bountyKeys.lists(), + }); + }); + + it("useRaiseDispute does not invalidate bounty queries when the POST fails", async () => { + const { queryClient, wrapper } = createHarness(); + (post as jest.Mock).mockRejectedValue(new Error("network failed")); + const invalidateSpy = jest.spyOn(queryClient, "invalidateQueries"); + const { result } = renderHook(() => useRaiseDispute(), { wrapper }); + await expect( + result.current.mutateAsync({ + bountyId, + reason: DisputeReasonEnum.Other, + description: "Need mediation", + }), + ).rejects.toThrow("network failed"); + expect(invalidateSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/hooks/use-application-mutations.ts b/hooks/use-application-mutations.ts index ffe3c743..b1924efd 100644 --- a/hooks/use-application-mutations.ts +++ b/hooks/use-application-mutations.ts @@ -4,6 +4,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { bountyKeys } from "@/lib/query/query-keys"; import { type BountyQuery } from "@/lib/graphql/generated"; import { + ApplicationError, resolveApplicationClient, toBountyIdBigInt, } from "./use-application-contracts"; @@ -36,6 +37,12 @@ export function useApplyToBounty() { applicantAddress: string; proposal: string; }) => { + if (!applicantAddress?.trim()) { + throw new ApplicationError( + "tx_failed", + "Wallet address is required to apply to a bounty.", + ); + } const client = resolveApplicationClient(); return client.apply({ applicant: applicantAddress,