prodobit
Version:
Open-core business application development platform
718 lines (585 loc) • 19.4 kB
text/typescript
import React from 'react'
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useItems, useItem, useCreateItem, useUpdateItem, useDeleteItem } from '../useItems.js'
import { ProdobitProvider } from '../../providers/ProdobitProvider.js'
import { ProdobitClient } from '@prodobit/sdk'
import type { ReactNode } from 'react'
// Mock the SDK client
vi.mock('@prodobit/sdk')
const MockProdobitClient = ProdobitClient as any
describe('useItems hooks', () => {
let queryClient: QueryClient
let mockClient: any
let wrapper: ({ children }: { children: ReactNode }) => JSX.Element
beforeEach(() => {
vi.clearAllMocks()
// Create fresh query client for each test
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
})
// Mock ProdobitClient
mockClient = {
getItems: vi.fn(),
getItem: vi.fn(),
createItem: vi.fn(),
updateItem: vi.fn(),
deleteItem: vi.fn(),
auth: {
getCurrentUser: vi.fn(),
isAuthenticated: vi.fn().mockReturnValue(true)
}
}
MockProdobitClient.mockImplementation(() => mockClient)
// Create wrapper with providers
wrapper = ({ children }: { children: ReactNode }) => {
return React.createElement(
QueryClientProvider,
{ client: queryClient },
React.createElement(
ProdobitProvider,
{ client: mockClient },
children
)
)
}
})
afterEach(() => {
vi.restoreAllMocks()
queryClient.clear()
})
describe('useItems', () => {
const mockItems = [
{
id: 'item-1',
name: 'Product 1',
sku: 'PROD-001',
unitPrice: '99.99',
category: 'Electronics',
tenantId: 'tenant-123'
},
{
id: 'item-2',
name: 'Product 2',
sku: 'PROD-002',
unitPrice: '149.99',
category: 'Electronics',
tenantId: 'tenant-123'
}
]
it('should fetch items successfully', async () => {
mockClient.getItems.mockResolvedValue({
items: mockItems,
total: 2
})
const { result } = renderHook(() => useItems(), { wrapper })
expect(result.current.isLoading).toBe(true)
expect(result.current.data).toBeUndefined()
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.data).toEqual({
items: mockItems,
total: 2
})
expect(result.current.error).toBeNull()
expect(mockClient.getItems).toHaveBeenCalledWith()
})
it('should handle fetch errors', async () => {
const error = new Error('Failed to fetch items')
mockClient.getItems.mockRejectedValue(error)
const { result } = renderHook(() => useItems(), { wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.data).toBeUndefined()
expect(result.current.error).toBe(error)
})
it('should support query options', async () => {
mockClient.getItems.mockResolvedValue({
items: mockItems,
total: 2
})
const { result } = renderHook(() =>
useItems({
enabled: false,
refetchOnWindowFocus: false
}),
{ wrapper }
)
// Should not fetch initially because enabled is false
expect(result.current.isLoading).toBe(false)
expect(mockClient.getItems).not.toHaveBeenCalled()
// Manually refetch
await act(async () => {
await result.current.refetch()
})
expect(mockClient.getItems).toHaveBeenCalled()
})
it('should support filtering parameters', async () => {
const filteredItems = [mockItems[0]]
mockClient.getItems.mockResolvedValue({
items: filteredItems,
total: 1
})
const { result } = renderHook(() =>
useItems({
category: 'Electronics',
minPrice: '50',
maxPrice: '100'
}),
{ wrapper }
)
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(mockClient.getItems).toHaveBeenCalledWith({
category: 'Electronics',
minPrice: '50',
maxPrice: '100'
})
expect(result.current.data).toEqual({
items: filteredItems,
total: 1
})
})
it('should support pagination', async () => {
mockClient.getItems.mockResolvedValue({
items: mockItems,
total: 2,
page: 1,
limit: 10
})
const { result } = renderHook(() =>
useItems({
page: 1,
limit: 10
}),
{ wrapper }
)
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(mockClient.getItems).toHaveBeenCalledWith({
page: 1,
limit: 10
})
})
})
describe('useItem', () => {
const mockItem = {
id: 'item-123',
name: 'Test Product',
sku: 'TEST-001',
unitPrice: '199.99',
category: 'Test Category',
description: 'A test product',
itemAttributes: [
{
attribute: { name: 'Color', type: 'text' },
value: 'Red'
}
]
}
it('should fetch single item successfully', async () => {
mockClient.getItem.mockResolvedValue(mockItem)
const { result } = renderHook(() => useItem('item-123'), { wrapper })
expect(result.current.isLoading).toBe(true)
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.data).toEqual(mockItem)
expect(result.current.error).toBeNull()
expect(mockClient.getItem).toHaveBeenCalledWith('item-123')
})
it('should handle item not found', async () => {
mockClient.getItem.mockResolvedValue(null)
const { result } = renderHook(() => useItem('non-existent'), { wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.data).toBeNull()
expect(result.current.error).toBeNull()
})
it('should not fetch when id is not provided', () => {
const { result } = renderHook(() => useItem(''), { wrapper })
expect(result.current.isLoading).toBe(false)
expect(result.current.data).toBeUndefined()
expect(mockClient.getItem).not.toHaveBeenCalled()
})
it('should handle fetch errors', async () => {
const error = new Error('Failed to fetch item')
mockClient.getItem.mockRejectedValue(error)
const { result } = renderHook(() => useItem('item-123'), { wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.data).toBeUndefined()
expect(result.current.error).toBe(error)
})
})
describe('useCreateItem', () => {
const newItemData = {
name: 'New Product',
sku: 'NEW-001',
unitPrice: '299.99',
category: 'Electronics',
description: 'A new product'
}
const createdItem = {
id: 'item-456',
...newItemData,
tenantId: 'tenant-123',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
it('should create item successfully', async () => {
mockClient.createItem.mockResolvedValue(createdItem)
const { result } = renderHook(() => useCreateItem(), { wrapper })
expect(result.current.isPending).toBe(false)
await act(async () => {
await result.current.mutateAsync(newItemData)
})
expect(result.current.isPending).toBe(false)
expect(result.current.isSuccess).toBe(true)
expect(result.current.data).toEqual(createdItem)
expect(mockClient.createItem).toHaveBeenCalledWith(newItemData)
})
it('should handle creation errors', async () => {
const error = new Error('SKU already exists')
mockClient.createItem.mockRejectedValue(error)
const { result } = renderHook(() => useCreateItem(), { wrapper })
await act(async () => {
try {
await result.current.mutateAsync(newItemData)
} catch (err) {
// Expected error
}
})
expect(result.current.isError).toBe(true)
expect(result.current.error).toBe(error)
})
it('should invalidate items cache on success', async () => {
mockClient.createItem.mockResolvedValue(createdItem)
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries')
const { result } = renderHook(() =>
useCreateItem({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['items'] })
}
}),
{ wrapper }
)
await act(async () => {
await result.current.mutateAsync(newItemData)
})
expect(invalidateQueriesSpy).toHaveBeenCalledWith({
queryKey: ['items']
})
})
it('should call success callback', async () => {
mockClient.createItem.mockResolvedValue(createdItem)
const onSuccess = vi.fn()
const { result } = renderHook(() =>
useCreateItem({
onSuccess
}),
{ wrapper }
)
await act(async () => {
await result.current.mutateAsync(newItemData)
})
expect(onSuccess).toHaveBeenCalledWith(createdItem, newItemData, expect.any(Object))
})
it('should call error callback', async () => {
const error = new Error('Creation failed')
mockClient.createItem.mockRejectedValue(error)
const onError = vi.fn()
const { result } = renderHook(() =>
useCreateItem({
onError
}),
{ wrapper }
)
await act(async () => {
try {
await result.current.mutateAsync(newItemData)
} catch (err) {
// Expected error
}
})
expect(onError).toHaveBeenCalledWith(error, newItemData, expect.any(Object))
})
})
describe('useUpdateItem', () => {
const updateData = {
name: 'Updated Product',
unitPrice: '399.99',
description: 'Updated description'
}
const updatedItem = {
id: 'item-123',
...updateData,
sku: 'EXISTING-001',
category: 'Electronics',
tenantId: 'tenant-123',
updatedAt: new Date().toISOString()
}
it('should update item successfully', async () => {
mockClient.updateItem.mockResolvedValue(updatedItem)
const { result } = renderHook(() => useUpdateItem(), { wrapper })
await act(async () => {
await result.current.mutateAsync({
id: 'item-123',
data: updateData
})
})
expect(result.current.isSuccess).toBe(true)
expect(result.current.data).toEqual(updatedItem)
expect(mockClient.updateItem).toHaveBeenCalledWith('item-123', updateData)
})
it('should handle update errors', async () => {
const error = new Error('Item not found')
mockClient.updateItem.mockRejectedValue(error)
const { result } = renderHook(() => useUpdateItem(), { wrapper })
await act(async () => {
try {
await result.current.mutateAsync({
id: 'non-existent',
data: updateData
})
} catch (err) {
// Expected error
}
})
expect(result.current.isError).toBe(true)
expect(result.current.error).toBe(error)
})
it('should invalidate item cache on success', async () => {
mockClient.updateItem.mockResolvedValue(updatedItem)
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries')
const { result } = renderHook(() =>
useUpdateItem({
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ['items'] })
queryClient.invalidateQueries({ queryKey: ['item', variables.id] })
}
}),
{ wrapper }
)
await act(async () => {
await result.current.mutateAsync({
id: 'item-123',
data: updateData
})
})
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['items'] })
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['item', 'item-123'] })
})
})
describe('useDeleteItem', () => {
it('should delete item successfully', async () => {
mockClient.deleteItem.mockResolvedValue(true)
const { result } = renderHook(() => useDeleteItem(), { wrapper })
await act(async () => {
await result.current.mutateAsync('item-123')
})
expect(result.current.isSuccess).toBe(true)
expect(result.current.data).toBe(true)
expect(mockClient.deleteItem).toHaveBeenCalledWith('item-123')
})
it('should handle delete errors', async () => {
const error = new Error('Failed to delete item')
mockClient.deleteItem.mockRejectedValue(error)
const { result } = renderHook(() => useDeleteItem(), { wrapper })
await act(async () => {
try {
await result.current.mutateAsync('item-123')
} catch (err) {
// Expected error
}
})
expect(result.current.isError).toBe(true)
expect(result.current.error).toBe(error)
})
it('should remove item from cache on success', async () => {
mockClient.deleteItem.mockResolvedValue(true)
const removeQueriesSpy = vi.spyOn(queryClient, 'removeQueries')
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries')
const { result } = renderHook(() =>
useDeleteItem({
onSuccess: (data, itemId) => {
queryClient.removeQueries({ queryKey: ['item', itemId] })
queryClient.invalidateQueries({ queryKey: ['items'] })
}
}),
{ wrapper }
)
await act(async () => {
await result.current.mutateAsync('item-123')
})
expect(removeQueriesSpy).toHaveBeenCalledWith({ queryKey: ['item', 'item-123'] })
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['items'] })
})
it('should call confirmation callback before delete', async () => {
mockClient.deleteItem.mockResolvedValue(true)
const onMutate = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
useDeleteItem({
onMutate
}),
{ wrapper }
)
await act(async () => {
await result.current.mutateAsync('item-123')
})
expect(onMutate).toHaveBeenCalledWith('item-123')
})
})
describe('search and filtering', () => {
it('should search items', async () => {
const searchResults = [
{
id: 'item-1',
name: 'Computer Laptop',
sku: 'COMP-001'
},
{
id: 'item-2',
name: 'Computer Mouse',
sku: 'COMP-002'
}
]
mockClient.getItems.mockResolvedValue({
items: searchResults,
total: 2,
query: 'computer'
})
// This would be a separate hook like useSearchItems
// For this test, we'll mock it
const useSearchItems = (query: string) => {
return renderHook(() =>
useItems({
search: query
})
).result
}
const { current } = useSearchItems('computer')
await waitFor(() => {
expect(current.current.isLoading).toBe(false)
})
// This would be handled by the useItems hook with search parameter
// The actual implementation would depend on how search is integrated
})
it('should filter items by category', async () => {
const categoryItems = [
{
id: 'item-1',
name: 'Laptop',
category: 'Electronics'
}
]
mockClient.getItems.mockResolvedValue({
items: categoryItems,
total: 1,
category: 'Electronics'
})
// This would be a separate hook like useItemsByCategory
const useItemsByCategory = (category: string) => {
return renderHook(() =>
useItems({
category
})
).result
}
const { current } = useItemsByCategory('Electronics')
await waitFor(() => {
expect(current.current.isLoading).toBe(false)
})
})
})
describe('optimistic updates', () => {
it('should perform optimistic update on item creation', async () => {
const newItem = {
name: 'Optimistic Item',
sku: 'OPT-001',
unitPrice: '99.99'
}
const createdItem = {
id: 'item-optimistic',
...newItem,
tenantId: 'tenant-123'
}
mockClient.createItem.mockResolvedValue(createdItem)
// Mock existing items in cache
queryClient.setQueryData(['items'], {
items: [],
total: 0
})
const { result } = renderHook(() =>
useCreateItem({
onMutate: async (newItemData) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['items'] })
// Snapshot the previous value
const previousItems = queryClient.getQueryData(['items'])
// Optimistically update cache
queryClient.setQueryData(['items'], (old: any) => ({
items: [...(old?.items || []), { id: 'temp-id', ...newItemData }],
total: (old?.total || 0) + 1
}))
return { previousItems }
},
onError: (err, newItem, context) => {
// Rollback on error
if (context?.previousItems) {
queryClient.setQueryData(['items'], context.previousItems)
}
}
}),
{ wrapper }
)
await act(async () => {
await result.current.mutateAsync(newItem)
})
expect(result.current.isSuccess).toBe(true)
})
})
describe('cache management', () => {
it('should handle stale data correctly', async () => {
const staleItems = [
{ id: 'item-1', name: 'Stale Product' }
]
const freshItems = [
{ id: 'item-1', name: 'Fresh Product' }
]
mockClient.getItems
.mockResolvedValueOnce({ items: staleItems, total: 1 })
.mockResolvedValueOnce({ items: freshItems, total: 1 })
const { result, rerender } = renderHook(() =>
useItems({
staleTime: 0, // Immediately stale
cacheTime: 5 * 60 * 1000 // Keep in cache for 5 minutes
}),
{ wrapper }
)
await waitFor(() => {
expect(result.current.data).toEqual({ items: staleItems, total: 1 })
})
// Rerender should trigger refetch due to staleTime: 0
rerender()
await waitFor(() => {
expect(result.current.data).toEqual({ items: freshItems, total: 1 })
})
expect(mockClient.getItems).toHaveBeenCalledTimes(2)
})
})
})