Frontend API structure.

This commit is contained in:
Drew 2025-09-22 02:00:26 -07:00
parent 7d2b7fc90c
commit c443a13a14
10 changed files with 1810 additions and 4 deletions

View file

@ -0,0 +1,359 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { useApi, useApiForm, useApiCache } from './useApi'
// Mock API function for testing
const mockApiFunction = vi.fn()
const mockFormSubmitFunction = vi.fn()
const mockCacheFunction = vi.fn()
describe('useApi', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('should initialize with default state', () => {
const { result } = renderHook(() => useApi(mockApiFunction))
expect(result.current.data).toBeNull()
expect(result.current.loading).toBe(false)
expect(result.current.error).toBeNull()
})
it('should execute API function successfully', async () => {
const mockData = { id: 1, name: 'Test' }
mockApiFunction.mockResolvedValueOnce(mockData)
const { result } = renderHook(() => useApi(mockApiFunction))
let executeResult: unknown
await act(async () => {
executeResult = await result.current.execute('test-arg')
})
expect(result.current.data).toEqual(mockData)
expect(result.current.loading).toBe(false)
expect(result.current.error).toBeNull()
expect(executeResult).toEqual(mockData)
expect(mockApiFunction).toHaveBeenCalledWith('test-arg')
})
it('should handle API function errors', async () => {
const errorMessage = 'API Error'
mockApiFunction.mockRejectedValueOnce({ message: errorMessage })
const { result } = renderHook(() => useApi(mockApiFunction))
let executeResult: unknown
await act(async () => {
executeResult = await result.current.execute()
})
expect(result.current.data).toBeNull()
expect(result.current.loading).toBe(false)
expect(result.current.error).toBe(errorMessage)
expect(executeResult).toBeNull()
})
it('should call onSuccess callback', async () => {
const mockData = { id: 1 }
const onSuccess = vi.fn()
mockApiFunction.mockResolvedValueOnce(mockData)
const { result } = renderHook(() => useApi(mockApiFunction, { onSuccess }))
await act(async () => {
await result.current.execute()
})
expect(onSuccess).toHaveBeenCalledWith(mockData)
})
it('should call onError callback', async () => {
const errorMessage = 'Test Error'
const onError = vi.fn()
mockApiFunction.mockRejectedValueOnce({ message: errorMessage })
const { result } = renderHook(() => useApi(mockApiFunction, { onError }))
await act(async () => {
await result.current.execute()
})
expect(onError).toHaveBeenCalledWith(errorMessage)
})
it('should execute immediately when immediate option is true', async () => {
const mockData = { id: 1 }
mockApiFunction.mockResolvedValueOnce(mockData)
renderHook(() => useApi(mockApiFunction, { immediate: true }))
await waitFor(() => {
expect(mockApiFunction).toHaveBeenCalled()
})
})
it('should reset state', async () => {
const mockData = { id: 1 }
mockApiFunction.mockResolvedValueOnce(mockData)
const { result } = renderHook(() => useApi(mockApiFunction))
await act(async () => {
await result.current.execute()
})
expect(result.current.data).toEqual(mockData)
act(() => {
result.current.reset()
})
expect(result.current.data).toBeNull()
expect(result.current.loading).toBe(false)
expect(result.current.error).toBeNull()
})
it('should clear error', async () => {
mockApiFunction.mockRejectedValueOnce({ message: 'Test Error' })
const { result } = renderHook(() => useApi(mockApiFunction))
await act(async () => {
await result.current.execute()
})
expect(result.current.error).toBeTruthy()
act(() => {
result.current.clearError()
})
expect(result.current.error).toBeNull()
})
it('should cancel previous requests', async () => {
let resolveFirst: (value: unknown) => void
let resolveSecond: (value: unknown) => void
const firstPromise = new Promise(resolve => {
resolveFirst = resolve
})
const secondPromise = new Promise(resolve => {
resolveSecond = resolve
})
mockApiFunction
.mockReturnValueOnce(firstPromise)
.mockReturnValueOnce(secondPromise)
const { result } = renderHook(() => useApi(mockApiFunction))
// Start first request
act(() => {
result.current.execute('first')
})
// Start second request before first completes
act(() => {
result.current.execute('second')
})
// Resolve first request (should be cancelled)
await act(async () => {
resolveFirst({ data: 'first' })
await new Promise(resolve => setTimeout(resolve, 0))
})
// Resolve second request
await act(async () => {
resolveSecond({ data: 'second' })
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(result.current.data).toEqual({ data: 'second' })
})
})
describe('useApiForm', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should handle form submission', async () => {
const formData = { name: 'Test' }
const responseData = { id: 1, name: 'Test' }
mockFormSubmitFunction.mockResolvedValueOnce(responseData)
const { result } = renderHook(() => useApiForm(mockFormSubmitFunction))
let submitResult: unknown
await act(async () => {
submitResult = await result.current.handleSubmit(formData)
})
expect(submitResult).toEqual(responseData)
expect(mockFormSubmitFunction).toHaveBeenCalledWith(formData)
expect(result.current.data).toEqual(responseData)
})
it('should reset on success when option is enabled', async () => {
const formData = { name: 'Test' }
const responseData = { id: 1, name: 'Test' }
mockFormSubmitFunction.mockResolvedValueOnce(responseData)
const { result } = renderHook(() =>
useApiForm(mockFormSubmitFunction, { resetOnSuccess: true })
)
await act(async () => {
await result.current.handleSubmit(formData)
})
expect(result.current.data).toBeNull()
})
})
describe('useApiCache', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('should fetch and cache data', async () => {
const mockData = { id: 1, name: 'Test' }
mockCacheFunction.mockResolvedValueOnce(mockData)
const { result } = renderHook(() =>
useApiCache('test-key', mockCacheFunction)
)
let fetchResult: unknown
await act(async () => {
fetchResult = await result.current.fetchData()
})
expect(fetchResult).toEqual(mockData)
expect(result.current.data).toEqual(mockData)
expect(mockCacheFunction).toHaveBeenCalledTimes(1)
})
it('should return cached data when fresh', async () => {
const mockData = { id: 1, name: 'Test' }
mockCacheFunction.mockResolvedValueOnce(mockData)
const { result } = renderHook(() =>
useApiCache('test-key', mockCacheFunction, { staleTime: 60000 })
)
// First fetch
await act(async () => {
await result.current.fetchData()
})
// Second fetch should return cached data
let secondResult: unknown
await act(async () => {
secondResult = await result.current.fetchData()
})
expect(secondResult).toEqual(mockData)
expect(mockCacheFunction).toHaveBeenCalledTimes(1) // Should not call API again
})
it('should fetch fresh data when stale', async () => {
const mockData1 = { id: 1, name: 'Test 1' }
const mockData2 = { id: 2, name: 'Test 2' }
mockCacheFunction
.mockResolvedValueOnce(mockData1)
.mockResolvedValueOnce(mockData2)
const { result } = renderHook(() =>
useApiCache('test-key', mockCacheFunction, { staleTime: 1000 })
)
// First fetch
await act(async () => {
await result.current.fetchData()
})
expect(result.current.data).toEqual(mockData1)
// Advance time past stale time
act(() => {
vi.advanceTimersByTime(2000)
})
// Second fetch should get fresh data
await act(async () => {
await result.current.fetchData()
})
expect(result.current.data).toEqual(mockData2)
expect(mockCacheFunction).toHaveBeenCalledTimes(2)
})
it('should clear cache', async () => {
const mockData = { id: 1, name: 'Test' }
mockCacheFunction.mockResolvedValueOnce(mockData)
const { result } = renderHook(() =>
useApiCache('test-key', mockCacheFunction)
)
await act(async () => {
await result.current.fetchData()
})
expect(result.current.data).toEqual(mockData)
await act(async () => {
result.current.clearCache()
})
expect(result.current.data).toBeNull()
})
it('should indicate stale status', async () => {
const mockData = { id: 1, name: 'Test' }
mockCacheFunction.mockResolvedValueOnce(mockData)
const { result } = renderHook(() =>
useApiCache('test-key', mockCacheFunction, { staleTime: 1000 })
)
// Initially stale (no data)
expect(result.current.isStale).toBe(true)
// Fetch data
await act(async () => {
await result.current.fetchData()
})
// Should be fresh
expect(result.current.isStale).toBe(false)
// Advance time past stale time
act(() => {
vi.advanceTimersByTime(2000)
})
// Should be stale again
expect(result.current.isStale).toBe(true)
})
})