Skip to content
Merged
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
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
legacy-peer-deps=true
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/constants/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ export const API_BASE_URL =

export const STELLAR_NETWORK = 'TESTNET'

export const SOROBAN_RPC_URL =
import.meta.env?.VITE_SOROBAN_RPC_URL || 'https://soroban-testnet.stellar.org'


export const CONTRACT_IDS = {
creditline:
'CAQDHYG3TALPNXG466SZUMJEPOI7VYV732LPFF3GHE4ASPBCNMIQBS3X',
Expand Down
147 changes: 147 additions & 0 deletions src/hooks/__tests__/optimisticMutations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
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 { useAddProduct, useUpdateProduct } from '../useOptimisticProduct'
import { queryKeys } from '../../services/queryKeys'
import type { VouchRequest, VendorProduct } from '../../types'

vi.mock('../../services/vouching.service', () => ({
vouchingService: {
submitVouch: vi.fn().mockImplementation((learnerAddress: string) => {
if (learnerAddress === 'FAIL_ADDRESS') {
return Promise.reject(new Error('Vouch failed'))
}
return Promise.resolve({ id: 'v1', status: 'approved' })
}),
revokeVouch: vi.fn().mockImplementation((id: string) => {
if (id === 'FAIL_ID') {
return Promise.reject(new Error('Revoke failed'))
}
return Promise.resolve()
}),
},
}))

vi.mock('../../services/vendors.service', () => ({
vendorsService: {
createProduct: vi.fn().mockImplementation((prod: { name: string; price: number }) => {
if (prod.name === 'FAIL_PRODUCT') {
return Promise.reject(new Error('Create failed'))
}
return Promise.resolve({ id: 'p1', ...prod, active: true, createdAt: new Date().toISOString() })
}),
updateProduct: vi.fn().mockImplementation((id: string) => {
if (id === 'FAIL_ID') {
return Promise.reject(new Error('Update failed'))
}
return Promise.resolve()
}),
deleteProduct: vi.fn().mockImplementation((id: string) => {
if (id === 'FAIL_ID') {
return Promise.reject(new Error('Delete failed'))
}
return Promise.resolve()
}),
},
}))

describe('Optimistic Mutation Hooks', () => {
let queryClient: QueryClient

const wrapper = ({ children }: { children: ReactNode }) =>
createElement(QueryClientProvider, { client: queryClient }, children)

beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
})

describe('useSubmitVouch', () => {
it('optimistically removes request from cache and restores on failure', 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(() => useSubmitVouch(), { wrapper })

await act(async () => {
try {
await result.current.mutateAsync({ learnerAddress: '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[] = [
{ id: 'p0', name: 'Existing Item', price: 50, active: true, createdAt: '2026-01-01' },
]

queryClient.setQueryData(queryKeys.vendors.products(), initialProducts)

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

await act(async () => {
try {
await result.current.mutateAsync({ name: 'FAIL_PRODUCT', price: 100 })
} catch {
// Expected rejection
}
})

const cached = queryClient.getQueryData<VendorProduct[]>(queryKeys.vendors.products())
expect(cached).toEqual(initialProducts)
})
})

describe('useUpdateProduct', () => {
it('optimistically updates product and rolls back on failure', async () => {
const initialProducts: VendorProduct[] = [
{ id: 'p1', name: 'Test Product', price: 100, active: true, createdAt: '2026-01-01' },
]

queryClient.setQueryData(queryKeys.vendors.products(), initialProducts)

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

await act(async () => {
try {
await result.current.mutateAsync({ id: 'FAIL_ID', data: { name: 'Updated Name' } })
} catch {
// Expected rejection
}
})

const cached = queryClient.getQueryData<VendorProduct[]>(queryKeys.vendors.products())
expect(cached).toEqual(initialProducts)
})
})
})
86 changes: 86 additions & 0 deletions src/hooks/useOptimisticDeposit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { transactionsService } from '../services/transactions.service'
import { queryKeys, invalidateSubtree } from '../services/queryKeys'
import type { PoolInfo } from '../types'

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

return useMutation({
mutationFn: async ({
signedXdr,
depositAmount,
}: {
signedXdr: string
depositAmount: number
}) => {
const submitted = await transactionsService.submit(signedXdr, 'deposit')
return { submitted, depositAmount }
},
onMutate: async ({ depositAmount }) => {
await queryClient.cancelQueries({ queryKey: queryKeys.pool.info() })

const previousPoolInfo = queryClient.getQueryData<PoolInfo>(queryKeys.pool.info())

if (previousPoolInfo) {
queryClient.setQueryData<PoolInfo>(queryKeys.pool.info(), {
...previousPoolInfo,
totalDeposits: previousPoolInfo.totalDeposits + depositAmount,
totalLiquidity: previousPoolInfo.totalLiquidity + depositAmount,
availableLiquidity: previousPoolInfo.availableLiquidity + depositAmount,
})
}

return { previousPoolInfo }
},
onError: (_err, _variables, context) => {
if (context?.previousPoolInfo) {
queryClient.setQueryData(queryKeys.pool.info(), context.previousPoolInfo)
}
},
onSettled: () => {
invalidateSubtree.pool(queryClient)
invalidateSubtree.loans(queryClient)
},
})
}

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

return useMutation({
mutationFn: async ({
signedXdr,
expectedAmount,
}: {
signedXdr: string
expectedAmount: number
}) => {
const submitted = await transactionsService.submit(signedXdr, 'withdraw')
return { submitted, expectedAmount }
},
onMutate: async ({ expectedAmount }) => {
await queryClient.cancelQueries({ queryKey: queryKeys.pool.info() })

const previousPoolInfo = queryClient.getQueryData<PoolInfo>(queryKeys.pool.info())

if (previousPoolInfo) {
queryClient.setQueryData<PoolInfo>(queryKeys.pool.info(), {
...previousPoolInfo,
totalLiquidity: Math.max(0, previousPoolInfo.totalLiquidity - expectedAmount),
availableLiquidity: Math.max(0, previousPoolInfo.availableLiquidity - expectedAmount),
})
}

return { previousPoolInfo }
},
onError: (_err, _variables, context) => {
if (context?.previousPoolInfo) {
queryClient.setQueryData(queryKeys.pool.info(), context.previousPoolInfo)
}
},
onSettled: () => {
invalidateSubtree.pool(queryClient)
},
})
}
126 changes: 126 additions & 0 deletions src/hooks/useOptimisticProduct.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { vendorsService } from '../services/vendors.service'
import { queryKeys, invalidateSubtree } from '../services/queryKeys'
import type { VendorProduct } from '../types'

interface CreateProductParams {
name: string
price: number
category?: string
description?: string
}

interface UpdateProductParams {
id: string
data: {
name?: string
price?: number
category?: string
description?: string
}
}

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

return useMutation({
mutationFn: (params: CreateProductParams) => vendorsService.createProduct(params),
onMutate: async (newProduct) => {
await queryClient.cancelQueries({ queryKey: queryKeys.vendors.products() })

const previousProducts = queryClient.getQueryData<VendorProduct[]>(
queryKeys.vendors.products()
)

if (previousProducts) {
const optimisticItem: VendorProduct = {
id: `temp-${Date.now()}`,
name: newProduct.name,
price: newProduct.price,
description: newProduct.description,
active: true,
createdAt: new Date().toISOString(),
}
queryClient.setQueryData<VendorProduct[]>(queryKeys.vendors.products(), [
...previousProducts,
optimisticItem,
])
}

return { previousProducts }
},
onError: (_err, _variables, context) => {
if (context?.previousProducts) {
queryClient.setQueryData(queryKeys.vendors.products(), context.previousProducts)
}
},
onSettled: () => {
invalidateSubtree.vendorProducts(queryClient)
},
})
}

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

return useMutation({
mutationFn: ({ id, data }: UpdateProductParams) =>
vendorsService.updateProduct(id, data),
onMutate: async ({ id, data }) => {
await queryClient.cancelQueries({ queryKey: queryKeys.vendors.products() })

const previousProducts = queryClient.getQueryData<VendorProduct[]>(
queryKeys.vendors.products()
)

if (previousProducts) {
queryClient.setQueryData<VendorProduct[]>(
queryKeys.vendors.products(),
previousProducts.map((p) => (p.id === id ? { ...p, ...data } : p))
)
}

return { previousProducts }
},
onError: (_err, _variables, context) => {
if (context?.previousProducts) {
queryClient.setQueryData(queryKeys.vendors.products(), context.previousProducts)
}
},
onSettled: () => {
invalidateSubtree.vendorProducts(queryClient)
},
})
}

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

return useMutation({
mutationFn: (id: string) => vendorsService.deleteProduct(id),
onMutate: async (id: string) => {
await queryClient.cancelQueries({ queryKey: queryKeys.vendors.products() })

const previousProducts = queryClient.getQueryData<VendorProduct[]>(
queryKeys.vendors.products()
)

if (previousProducts) {
queryClient.setQueryData<VendorProduct[]>(
queryKeys.vendors.products(),
previousProducts.filter((p) => p.id !== id)
)
}

return { previousProducts }
},
onError: (_err, _id, context) => {
if (context?.previousProducts) {
queryClient.setQueryData(queryKeys.vendors.products(), context.previousProducts)
}
},
onSettled: () => {
invalidateSubtree.vendorProducts(queryClient)
},
})
}
Loading
Loading