From e171e9500666bd8891dced6c4763a51b830f9524 Mon Sep 17 00:00:00 2001 From: natemiller23 Date: Wed, 24 Jun 2026 03:50:58 -0700 Subject: [PATCH 1/2] feat(bounties): participant escrow hooks + regression test --- features/bounties/hooks/use-bounty-escrow.ts | 235 ++++++++++++++++++ .../hooks/bounties/use-bounty-escrow.test.ts | 52 ++++ 2 files changed, 287 insertions(+) create mode 100644 features/bounties/hooks/use-bounty-escrow.ts create mode 100644 tests/hooks/bounties/use-bounty-escrow.test.ts diff --git a/features/bounties/hooks/use-bounty-escrow.ts b/features/bounties/hooks/use-bounty-escrow.ts new file mode 100644 index 000000000..7ee4f39e0 --- /dev/null +++ b/features/bounties/hooks/use-bounty-escrow.ts @@ -0,0 +1,235 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { reportError } from '@/lib/error-reporting'; + +const BOUNDED_PREFIX = ['bounties'] as const; + +type BountySummary = { + id: string; + title: string; + description: string; + reward: string; + status: 'open' | 'in_progress' | 'completed'; + createdAt: string; + claimCount?: number; +}; + +type BountyApplication = { + id: string; + bountyId: string; + message: string; + status: 'pending' | 'accepted' | 'rejected'; + submittedAt: string; +}; + +type ParticipantActivityItem = + | { kind: 'application'; data: BountyApplication } + | { kind: 'submission'; data: { id: string; bountyId: string; status: string } }; + +async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(path, { + ...init, + headers: { + 'Content-Type': 'application/json', + ...(init?.headers ?? {}), + }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(body || `Request failed: ${res.status}`); + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; +} + +export function useBountiesList(params?: { page?: number; limit?: number }) { + return useQuery({ + queryKey: [...BOUNDED_PREFIX, 'list', params], + queryFn: async (): Promise<{ bounties: BountySummary[]; pagination: Record } => { + const search = new URLSearchParams({ page: String(params?.page ?? 1), limit: String(params?.limit ?? 20) }); + const data = await request<{ bounties: BountySummary[]; pagination: Record }>(`/api/bounties?${search}`); + return { bounties: data.bounties ?? [], pagination: data.pagination ?? {} }; + }, + staleTime: 30_000, + }); +} + +export function useBounty(id: string) { + return useQuery({ + queryKey: [...BOUNDED_PREFIX, 'detail', id], + queryFn: async () => { + const data = await request<{ bounty: BountySummary }>(`/api/bounties/${encodeURIComponent(id)}`); + return data.bounty; + }, + enabled: !!id, + staleTime: 30_000, + }); +} + +export function useMyBountyApplications(bountyId: string) { + return useQuery({ + queryKey: [...BOUNDED_PREFIX, 'my-applications', bountyId], + queryFn: async () => { + const data = await request<{ applications: BountyApplication[] }>(`/api/bounties/${encodeURIComponent(bountyId)}/applications/me`); + return data.applications ?? []; + }, + enabled: !!bountyId, + staleTime: 20_000, + }); +} + +export function useApplyToBounty(bountyId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (message: string) => { + const data = await request<{ application: BountyApplication }>(`/api/bounties/${encodeURIComponent(bountyId)}/applications`, { + method: 'POST', + body: JSON.stringify({ message }), + }); + return data.application; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [...BOUNDED_PREFIX, 'my-applications', bountyId] }); + toast.success('Application submitted'); + }, + onError: (err: unknown) => { + const message = err instanceof Error ? err.message : 'Failed to apply'; + reportError(err, { context: 'bounties-applyToBounty', bountyId }); + toast.error(message); + }, + }); +} + +export function useJoinCompetition(bountyId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (payload?: { teamName?: string; message?: string }) => { + const data = await request<{ application: BountyApplication }>(`/api/bounties/${encodeURIComponent(bountyId)}/join`, { + method: 'POST', + body: JSON.stringify({ type: 'TEAM', ...payload }), + }); + return data.application; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [...BOUNDED_PREFIX, 'my-applications', bountyId] }); + toast.success('Joined bounty'); + }, + onError: (err: unknown) => { + const message = err instanceof Error ? err.message : 'Failed to join competition'; + reportError(err, { context: 'bounties-joinCompetition', bountyId }); + toast.error(message); + }, + }); +} + +export function useEditApplication(bountyId: string, applicationId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (patch: Partial>) => { + const data = await request<{ application: BountyApplication }>(`/api/bounties/${encodeURIComponent(bountyId)}/applications/${encodeURIComponent(applicationId)}`, { + method: 'PATCH', + body: JSON.stringify(patch), + }); + return data.application; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [...BOUNDED_PREFIX, 'my-applications', bountyId] }); + toast.success('Application updated'); + }, + onError: (err: unknown) => { + const message = err instanceof Error ? err.message : 'Failed to update application'; + reportError(err, { context: 'bounties-editApplication', bountyId, applicationId }); + toast.error(message); + }, + }); +} + +export function useWithdrawApplication(bountyId: string, applicationId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async () => { + await request(`/api/bounties/${encodeURIComponent(bountyId)}/applications/${encodeURIComponent(applicationId)}`, { + method: 'DELETE', + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [...BOUNDED_PREFIX, 'my-applications', bountyId] }); + toast.success('Application withdrawn'); + }, + onError: (err: unknown) => { + const message = err instanceof Error ? err.message : 'Failed to withdraw application'; + reportError(err, { context: 'bounties-withdrawApplication', bountyId, applicationId }); + toast.error(message); + }, + }); +} + +async function runEscrowOp(expected: 'terminal' | 'any' = 'any'): Promise { + const data = await request('/api/escrow/ops', { + method: 'POST', + body: JSON.stringify({ scope: 'participant', mode: 'MANAGED' }), + }); + return data; +} + +export function useSubmitBounty(bountyId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (payload: { description: string; links?: Array<{ type?: string; url: string }> }) => { + const description = payload?.description; + const links = payload?.links; + if (!description) { + throw new Error('Description is required to submit bounty work'); + } + const result = await runEscrowOp<{ submission: { id: string; status: string } }>(); + if (!result || !('submission' in result)) { + throw new Error('Escrow operation did not return a submission'); + } + return result as { submission: { id: string; status: string } }; + }, + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: [...BOUNDED_PREFIX, bountyId] }); + toast.success(`Submission recorded${result?.submission?.id ? ': ' + result.submission.id : ''}`); + }, + onError: (err: unknown) => { + const message = err instanceof Error ? err.message : 'Failed to submit bounty work'; + reportError(err, { context: 'bounties-submitBounty', bountyId }); + toast.error(message); + }, + }); +} + +export function useWithdrawSubmission(bountyId: string, submissionId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async () => { + await request(`/api/bounties/${encodeURIComponent(bountyId)}/submissions/${encodeURIComponent(submissionId)}`, { + method: 'DELETE', + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [...BOUNDED_PREFIX, bountyId] }); + toast.success('Submission withdrawn'); + }, + onError: (err: unknown) => { + const message = err instanceof Error ? err.message : 'Failed to withdraw submission'; + reportError(err, { context: 'bounties-withdrawSubmission', bountyId, submissionId }); + toast.error(message); + }, + }); +} + +export function useMyBountyActivity(bountyId: string) { + const applications = useMyBountyApplications(bountyId); + return useQuery({ + queryKey: [...BOUNDED_PREFIX, 'my-activity', bountyId], + queryFn: async (): Promise => { + const apps = applications.data ?? []; + return apps.map(application => ({ kind: 'application', data: application })); + }, + enabled: !!bountyId && applications.isFetched, + }); +} diff --git a/tests/hooks/bounties/use-bounty-escrow.test.ts b/tests/hooks/bounties/use-bounty-escrow.test.ts new file mode 100644 index 000000000..b9bc1e506 --- /dev/null +++ b/tests/hooks/bounties/use-bounty-escrow.test.ts @@ -0,0 +1,52 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { describe, it, expect, vi } from 'vitest'; +import { + useBountiesList, + useBounty, + useMyBountyApplications, + useApplyToBounty, + useJoinCompetition, + useEditApplication, + useWithdrawApplication, + useSubmitBounty, + useWithdrawSubmission, + useMyBountyActivity, +} from '@/hooks/bounties/use-bounty-escrow'; + +function wrap(hook: () => unknown) { + const qc = new QueryClient(); + return renderHook(() => hook(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + }); +} + +describe('bounty escrow hooks contract', () => { + it('submits a bounty via escrow with description and validates payload', async () => { + const captured: unknown[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : String(input); + captured.push({ url, body: init?.body }); + if (url.includes('/api/escrow/ops')) { + return new Response(JSON.stringify({ submission: { id: 'sub_123', status: 'ok' } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + }) as typeof fetch; + + const { result } = wrap(() => useSubmitBounty('bounty-1')); + + await act(async () => { + await result.current.mutateAsync({ description: 'my submission payload' }); + }); + + expect(captured.some((c: any) => typeof c.url === 'string' && c.url.includes('/api/escrow/ops'))).toBe(true); + + globalThis.fetch = originalFetch; + }); +}); From 22539d9a92efb24c40b96002246edb5afc8165a1 Mon Sep 17 00:00:00 2001 From: natemiller23 Date: Wed, 24 Jun 2026 05:09:30 -0700 Subject: [PATCH 2/2] fix(bounties): address CodeRabbit review on #621 - close Promise<> generic in useBountiesList - forward payload through runEscrowOp / useSubmitBounty - invalidate bounty detail cache after submit/withdraw - include submissions in useMyBountyActivity - update test import path and typing --- features/bounties/hooks/use-bounty-escrow.ts | 40 ++++++++++--- .../hooks/bounties/use-bounty-escrow.test.ts | 60 ++++++++++++------- 2 files changed, 71 insertions(+), 29 deletions(-) diff --git a/features/bounties/hooks/use-bounty-escrow.ts b/features/bounties/hooks/use-bounty-escrow.ts index 7ee4f39e0..c763e7da8 100644 --- a/features/bounties/hooks/use-bounty-escrow.ts +++ b/features/bounties/hooks/use-bounty-escrow.ts @@ -48,7 +48,7 @@ async function request(path: string, init?: RequestInit): Promise { export function useBountiesList(params?: { page?: number; limit?: number }) { return useQuery({ queryKey: [...BOUNDED_PREFIX, 'list', params], - queryFn: async (): Promise<{ bounties: BountySummary[]; pagination: Record } => { + queryFn: async (): Promise<{ bounties: BountySummary[]; pagination: Record }> => { const search = new URLSearchParams({ page: String(params?.page ?? 1), limit: String(params?.limit ?? 20) }); const data = await request<{ bounties: BountySummary[]; pagination: Record }>(`/api/bounties?${search}`); return { bounties: data.bounties ?? [], pagination: data.pagination ?? {} }; @@ -167,10 +167,10 @@ export function useWithdrawApplication(bountyId: string, applicationId: string) }); } -async function runEscrowOp(expected: 'terminal' | 'any' = 'any'): Promise { +async function runEscrowOp(payload: Record): Promise { const data = await request('/api/escrow/ops', { method: 'POST', - body: JSON.stringify({ scope: 'participant', mode: 'MANAGED' }), + body: JSON.stringify({ scope: 'participant', mode: 'MANAGED', ...payload }), }); return data; } @@ -184,14 +184,18 @@ export function useSubmitBounty(bountyId: string) { if (!description) { throw new Error('Description is required to submit bounty work'); } - const result = await runEscrowOp<{ submission: { id: string; status: string } }>(); + const result = await runEscrowOp<{ submission: { id: string; status: string } }>({ + bountyId, + description, + links, + }); if (!result || !('submission' in result)) { throw new Error('Escrow operation did not return a submission'); } return result as { submission: { id: string; status: string } }; }, onSuccess: (result) => { - queryClient.invalidateQueries({ queryKey: [...BOUNDED_PREFIX, bountyId] }); + queryClient.invalidateQueries({ queryKey: [...BOUNDED_PREFIX, 'detail', bountyId] }); toast.success(`Submission recorded${result?.submission?.id ? ': ' + result.submission.id : ''}`); }, onError: (err: unknown) => { @@ -211,7 +215,7 @@ export function useWithdrawSubmission(bountyId: string, submissionId: string) { }); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [...BOUNDED_PREFIX, bountyId] }); + queryClient.invalidateQueries({ queryKey: [...BOUNDED_PREFIX, 'detail', bountyId] }); toast.success('Submission withdrawn'); }, onError: (err: unknown) => { @@ -222,14 +226,34 @@ export function useWithdrawSubmission(bountyId: string, submissionId: string) { }); } +type ParticipantSubmission = { id: string; bountyId: string; status: string }; + +type ParticipantActivityItem = + | { kind: 'application'; data: BountyApplication } + | { kind: 'submission'; data: ParticipantSubmission }; + export function useMyBountyActivity(bountyId: string) { const applications = useMyBountyApplications(bountyId); + const submissions = useQuery({ + queryKey: [...BOUNDED_PREFIX, 'my-submissions', bountyId], + queryFn: async (): Promise => { + const data = await request<{ submissions: ParticipantSubmission[] }>(`/api/bounties/${encodeURIComponent(bountyId)}/submissions/me`); + return data.submissions ?? []; + }, + enabled: !!bountyId, + staleTime: 20_000, + }); + return useQuery({ queryKey: [...BOUNDED_PREFIX, 'my-activity', bountyId], queryFn: async (): Promise => { const apps = applications.data ?? []; - return apps.map(application => ({ kind: 'application', data: application })); + const subs = submissions.data ?? []; + return [ + ...apps.map(application => ({ kind: 'application', data: application })), + ...subs.map(submission => ({ kind: 'submission', data: submission })), + ]; }, - enabled: !!bountyId && applications.isFetched, + enabled: !!bountyId && applications.isFetched && submissions.isFetched, }); } diff --git a/tests/hooks/bounties/use-bounty-escrow.test.ts b/tests/hooks/bounties/use-bounty-escrow.test.ts index b9bc1e506..dfb6b4d72 100644 --- a/tests/hooks/bounties/use-bounty-escrow.test.ts +++ b/tests/hooks/bounties/use-bounty-escrow.test.ts @@ -1,6 +1,7 @@ -import { renderHook, waitFor } from '@testing-library/react'; +import { renderHook, waitFor, act } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { describe, it, expect, vi } from 'vitest'; +import type { ReactNode } from 'react'; import { useBountiesList, useBounty, @@ -12,32 +13,42 @@ import { useSubmitBounty, useWithdrawSubmission, useMyBountyActivity, -} from '@/hooks/bounties/use-bounty-escrow'; +} from '@/features/bounties/hooks/use-bounty-escrow'; function wrap(hook: () => unknown) { - const qc = new QueryClient(); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return renderHook(() => hook(), { - wrapper: ({ children }: { children: React.ReactNode }) => ( + wrapper: ({ children }: { children: ReactNode }) => ( {children} ), }); } -describe('bounty escrow hooks contract', () => { - it('submits a bounty via escrow with description and validates payload', async () => { - const captured: unknown[] = []; - const originalFetch = globalThis.fetch; - globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : String(input); +describe('use-bounty-escrow', () => { + it('useBountiesList fetches bounties', async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ bounties: [{ id: 'b1', title: 'T', description: '', reward: '1', status: 'open', createdAt: '2026-01-01T00:00:00Z' }], pagination: { total: 1 } }), + } as Response), + ); + + const { result } = wrap(() => useBountiesList()); + await waitFor(() => result.current.isSuccess); + expect(result.current.data?.bounties).toHaveLength(1); + }); + + it('useSubmitBounty forwards payload to escrow op', async () => { + const captured: Array<{ url?: string; body?: BodyInit | null }> = []; + global.fetch = vi.fn((url: string, init?: RequestInit) => { captured.push({ url, body: init?.body }); - if (url.includes('/api/escrow/ops')) { - return new Response(JSON.stringify({ submission: { id: 'sub_123', status: 'ok' } }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - } - return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); - }) as typeof fetch; + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ submission: { id: 'sub_1', status: 'ok' } }), + } as Response); + }); const { result } = wrap(() => useSubmitBounty('bounty-1')); @@ -45,8 +56,15 @@ describe('bounty escrow hooks contract', () => { await result.current.mutateAsync({ description: 'my submission payload' }); }); - expect(captured.some((c: any) => typeof c.url === 'string' && c.url.includes('/api/escrow/ops'))).toBe(true); - - globalThis.fetch = originalFetch; + expect( + captured.some( + (c): c is { url: string; body?: BodyInit | null } => + typeof c.url === 'string' && + c.url.includes('/api/escrow/ops') && + typeof c.body === 'string' && + c.body.includes('bounty-1') && + c.body.includes('my submission payload'), + ), + ).toBe(true); }); });