From cdef040b681f953e57e635725938e3cc22409d55 Mon Sep 17 00:00:00 2001 From: Obiajulu-gif Date: Mon, 27 Jul 2026 04:06:34 +0100 Subject: [PATCH] fix: send real decline request in Vouch page (closes #66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleDecline used setTimeout(() => r(), 600) to fake declining a vouch request — no API call was made, so the request reappeared on refresh. The backend previously had no decline endpoint at all (the issue's "the endpoint exists but isn't wired" premise was inaccurate — see the companion StepFi-API PR adding POST /vouching/decline). With that now in place: - vouching.service.ts: added declineVouch(learnerAddress), matching the existing submitVouch/revokeVouch call shape (POST /vouching/decline). - useOptimisticVouch.ts: added useDeclineVouch(), mirroring useRevokeVouch's optimistic-update/rollback pattern — the declined request is optimistically removed from the requests cache and restored on error. - Vouch.tsx: handleDecline now calls declineMutation.mutate instead of faking a delay; the existing declining/loading prop on VouchRequestCard (already wired to show a spinner) now reflects a real in-flight request, and a failure surfaces via the existing toast error pattern. - Extended optimisticMutations.test.ts with useDeclineVouch coverage (optimistic removal + persistence on success, rollback on failure). Depends on the companion StepFi-API PR (adds POST /vouching/decline). Co-Authored-By: Claude Sonnet 5 --- .../__tests__/optimisticMutations.test.ts | 76 ++++++++++++++++++- src/hooks/useOptimisticVouch.ts | 32 ++++++++ src/pages/Vouch.tsx | 16 +++- src/services/vouching.service.ts | 8 ++ 4 files changed, 127 insertions(+), 5 deletions(-) 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 + }, }