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
76 changes: 75 additions & 1 deletion src/hooks/__tests__/optimisticMutations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { type ReactNode, createElement } from 'react'
import { useSubmitVouch } from '../useOptimisticVouch'
import { useSubmitVouch, useDeclineVouch } from '../useOptimisticVouch'
import { useAddProduct, useUpdateProduct } from '../useOptimisticProduct'
import { queryKeys } from '../../services/queryKeys'
import type { VouchRequest, VendorProduct } from '../../types'
Expand All @@ -21,6 +21,12 @@ vi.mock('../../services/vouching.service', () => ({
}
return Promise.resolve()
}),
declineVouch: vi.fn().mockImplementation((learnerAddress: string) => {
if (learnerAddress === 'FAIL_ADDRESS') {
return Promise.reject(new Error('Decline failed'))
}
return Promise.resolve({ id: 'v1', status: 'declined' })
}),
},
}))

Expand Down Expand Up @@ -99,6 +105,74 @@ describe('Optimistic Mutation Hooks', () => {
})
})

describe('useDeclineVouch', () => {
it('optimistically removes the request from cache and persists it on success', async () => {
const initialRequests: VouchRequest[] = [
{
id: 'req-1',
learnerAddress: 'G_LEARNER_1',
learnerWallet: 'G_LEARNER_1',
score: 80,
tier: 'Silver',
totalLoans: 2,
activeLoans: 1,
totalBorrowed: 500,
totalRepaid: 300,
loanAmount: 200,
purpose: 'Laptop',
requestedAt: '2026-01-01',
skills: ['Rust'],
},
]

queryClient.setQueryData(queryKeys.vouches.requests(), initialRequests)

const { result } = renderHook(() => useDeclineVouch(), { wrapper })

await act(async () => {
await result.current.mutateAsync('G_LEARNER_1')
})

const cached = queryClient.getQueryData<VouchRequest[]>(queryKeys.vouches.requests())
expect(cached).toEqual([])
})

it('rolls back the optimistic removal when the request fails', async () => {
const initialRequests: VouchRequest[] = [
{
id: 'req-1',
learnerAddress: 'FAIL_ADDRESS',
learnerWallet: 'FAIL_ADDRESS',
score: 80,
tier: 'Silver',
totalLoans: 2,
activeLoans: 1,
totalBorrowed: 500,
totalRepaid: 300,
loanAmount: 200,
purpose: 'Laptop',
requestedAt: '2026-01-01',
skills: ['Rust'],
},
]

queryClient.setQueryData(queryKeys.vouches.requests(), initialRequests)

const { result } = renderHook(() => useDeclineVouch(), { wrapper })

await act(async () => {
try {
await result.current.mutateAsync('FAIL_ADDRESS')
} catch {
// Expected rejection
}
})

const cached = queryClient.getQueryData<VouchRequest[]>(queryKeys.vouches.requests())
expect(cached).toEqual(initialRequests)
})
})

describe('useAddProduct', () => {
it('optimistically appends product and rolls back on failure', async () => {
const initialProducts: VendorProduct[] = [
Expand Down
32 changes: 32 additions & 0 deletions src/hooks/useOptimisticVouch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,38 @@ export function useSubmitVouch() {
})
}

export function useDeclineVouch() {
const queryClient = useQueryClient()

return useMutation({
mutationFn: (learnerAddress: string) => vouchingService.declineVouch(learnerAddress),
onMutate: async (learnerAddress: string) => {
await queryClient.cancelQueries({ queryKey: queryKeys.vouches.requests() })

const previousRequests = queryClient.getQueryData<VouchRequest[]>(
queryKeys.vouches.requests()
)

if (previousRequests) {
queryClient.setQueryData<VouchRequest[]>(
queryKeys.vouches.requests(),
previousRequests.filter((req) => req.learnerAddress !== learnerAddress)
)
}

return { previousRequests }
},
onError: (_err, _learnerAddress, context) => {
if (context?.previousRequests) {
queryClient.setQueryData(queryKeys.vouches.requests(), context.previousRequests)
}
},
onSettled: () => {
invalidateSubtree.vouches(queryClient)
},
})
}

export function useRevokeVouch() {
const queryClient = useQueryClient()

Expand Down
16 changes: 12 additions & 4 deletions src/pages/Vouch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ClipboardList, ShieldCheck, Award, AlertTriangle, RotateCw, Clock, Doll
import { signTransaction, isConnected, requestAccess } from '@stellar/freighter-api'
import { vouchingService } from '../services/vouching.service'
import { queryKeys } from '../services/queryKeys'
import { useSubmitVouch, useRevokeVouch } from '../hooks/useOptimisticVouch'
import { useSubmitVouch, useRevokeVouch, useDeclineVouch } from '../hooks/useOptimisticVouch'
import { Card } from '../components/ui/Card'
import { Button } from '../components/ui/Button'
import { Badge } from '../components/ui/Badge'
Expand Down Expand Up @@ -113,6 +113,7 @@ export function Vouch() {

const submitMutation = useSubmitVouch()
const revokeMutation = useRevokeVouch()
const declineMutation = useDeclineVouch()

const handleVouchConfirm = async () => {
if (!previewRequest) return
Expand Down Expand Up @@ -164,10 +165,17 @@ export function Vouch() {
}
}

const handleDecline = async (id: string) => {
const handleDecline = (id: string) => {
setDecliningId(id)
await new Promise((r) => setTimeout(r, 600))
setDecliningId(null)
declineMutation.mutate(id, {
onError: (error) => {
const message = error instanceof Error ? error.message : 'Failed to decline request.'
toast.error(message)
},
onSettled: () => {
setDecliningId(null)
},
})
}

const tabs = [
Expand Down
8 changes: 8 additions & 0 deletions src/services/vouching.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,12 @@ export const vouchingService = {
revokeVouch: async (id: string): Promise<void> => {
await api.delete(`/vouching/${id}`)
},

// POST /vouching/decline — mentor declines a pending vouch request for a learner.
declineVouch: async (learnerAddress: string): Promise<VouchResponse> => {
const res = await api.post<VouchResponse>('/vouching/decline', {
learnerWallet: learnerAddress,
})
return res.data
},
}