diff --git a/src/hooks/__tests__/optimisticMutations.test.ts b/src/hooks/__tests__/optimisticMutations.test.ts index 21ea24d..bcc20a4 100644 --- a/src/hooks/__tests__/optimisticMutations.test.ts +++ b/src/hooks/__tests__/optimisticMutations.test.ts @@ -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' @@ -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' }) + }), }, })) @@ -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(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(queryKeys.vouches.requests()) + expect(cached).toEqual(initialRequests) + }) + }) + describe('useAddProduct', () => { it('optimistically appends product and rolls back on failure', async () => { const initialProducts: VendorProduct[] = [ diff --git a/src/hooks/useOptimisticVouch.ts b/src/hooks/useOptimisticVouch.ts index 3130fbe..7295953 100644 --- a/src/hooks/useOptimisticVouch.ts +++ b/src/hooks/useOptimisticVouch.ts @@ -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( + queryKeys.vouches.requests() + ) + + if (previousRequests) { + queryClient.setQueryData( + 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() diff --git a/src/pages/Vouch.tsx b/src/pages/Vouch.tsx index ae6623b..704caf7 100644 --- a/src/pages/Vouch.tsx +++ b/src/pages/Vouch.tsx @@ -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' @@ -113,6 +113,7 @@ export function Vouch() { const submitMutation = useSubmitVouch() const revokeMutation = useRevokeVouch() + const declineMutation = useDeclineVouch() const handleVouchConfirm = async () => { if (!previewRequest) return @@ -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 = [ diff --git a/src/services/vouching.service.ts b/src/services/vouching.service.ts index 351c2e1..36711a0 100644 --- a/src/services/vouching.service.ts +++ b/src/services/vouching.service.ts @@ -95,4 +95,12 @@ export const vouchingService = { revokeVouch: async (id: string): Promise => { await api.delete(`/vouching/${id}`) }, + + // POST /vouching/decline — mentor declines a pending vouch request for a learner. + declineVouch: async (learnerAddress: string): Promise => { + const res = await api.post('/vouching/decline', { + learnerWallet: learnerAddress, + }) + return res.data + }, }