Frontend API structure.
This commit is contained in:
parent
7d2b7fc90c
commit
c443a13a14
10 changed files with 1810 additions and 4 deletions
359
frontend/app/hooks/useApi.test.ts
Normal file
359
frontend/app/hooks/useApi.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
227
frontend/app/hooks/useApi.ts
Normal file
227
frontend/app/hooks/useApi.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { ApiError } from '~/types/task'
|
||||
|
||||
interface UseApiState<T> {
|
||||
data: T | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface UseApiActions<T> {
|
||||
execute: (...args: unknown[]) => Promise<T | null>
|
||||
reset: () => void
|
||||
clearError: () => void
|
||||
}
|
||||
|
||||
interface UseApiOptions {
|
||||
immediate?: boolean
|
||||
onSuccess?: (data: unknown) => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
export function useApi<T>(
|
||||
apiFunction: (...args: unknown[]) => Promise<T>,
|
||||
options: UseApiOptions = {}
|
||||
): UseApiState<T> & UseApiActions<T> {
|
||||
const { immediate = false, onSuccess, onError } = options
|
||||
|
||||
const [state, setState] = useState<UseApiState<T>>({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mountedRef = useRef(true)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setState(prev => ({ ...prev, error: null }))
|
||||
}, [])
|
||||
|
||||
const execute = useCallback(
|
||||
async (...args: unknown[]): Promise<T | null> => {
|
||||
// Cancel previous request if still pending
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
|
||||
abortControllerRef.current = new AbortController()
|
||||
|
||||
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||
|
||||
try {
|
||||
const result = await apiFunction(...args)
|
||||
|
||||
if (!mountedRef.current) return null
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
data: result,
|
||||
loading: false,
|
||||
}))
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess(result)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return null
|
||||
|
||||
const apiError = error as ApiError
|
||||
const errorMessage = apiError.message || 'An unknown error occurred'
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: errorMessage,
|
||||
}))
|
||||
|
||||
if (onError) {
|
||||
onError(errorMessage)
|
||||
}
|
||||
|
||||
return null
|
||||
} finally {
|
||||
abortControllerRef.current = null
|
||||
}
|
||||
},
|
||||
[apiFunction, onSuccess, onError]
|
||||
)
|
||||
|
||||
// Execute immediately if requested
|
||||
useEffect(() => {
|
||||
if (immediate) {
|
||||
execute()
|
||||
}
|
||||
}, [immediate, execute])
|
||||
|
||||
return {
|
||||
...state,
|
||||
execute,
|
||||
reset,
|
||||
clearError,
|
||||
}
|
||||
}
|
||||
|
||||
// Utility hook for handling form submissions
|
||||
export function useApiForm<T>(
|
||||
submitFunction: (data: unknown) => Promise<T>,
|
||||
options: UseApiOptions & { resetOnSuccess?: boolean } = {}
|
||||
) {
|
||||
const { resetOnSuccess = false, ...apiOptions } = options
|
||||
|
||||
const api = useApi(submitFunction, apiOptions)
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (data: unknown) => {
|
||||
const result = await api.execute(data)
|
||||
|
||||
if (result && resetOnSuccess) {
|
||||
api.reset()
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
[api, resetOnSuccess]
|
||||
)
|
||||
|
||||
return {
|
||||
...api,
|
||||
handleSubmit,
|
||||
}
|
||||
}
|
||||
|
||||
// Utility hook for data caching and synchronization
|
||||
export function useApiCache<T>(
|
||||
key: string,
|
||||
fetchFunction: () => Promise<T>,
|
||||
options: { cacheTime?: number; staleTime?: number } = {}
|
||||
) {
|
||||
const { cacheTime = 5 * 60 * 1000, staleTime = 30 * 1000 } = options // 5min cache, 30s stale
|
||||
|
||||
const [cacheData, setCacheData] = useState<{
|
||||
data: T | null
|
||||
timestamp: number
|
||||
} | null>(null)
|
||||
|
||||
const api = useApi(fetchFunction)
|
||||
|
||||
const getCachedData = useCallback((): T | null => {
|
||||
if (!cacheData) return null
|
||||
|
||||
const now = Date.now()
|
||||
const age = now - cacheData.timestamp
|
||||
|
||||
if (age > cacheTime) {
|
||||
setCacheData(null)
|
||||
return null
|
||||
}
|
||||
|
||||
return cacheData.data
|
||||
}, [cacheData, cacheTime])
|
||||
|
||||
const isStale = useCallback((): boolean => {
|
||||
if (!cacheData) return true
|
||||
|
||||
const now = Date.now()
|
||||
const age = now - cacheData.timestamp
|
||||
|
||||
return age > staleTime
|
||||
}, [cacheData, staleTime])
|
||||
|
||||
const fetchData = useCallback(
|
||||
async (force = false): Promise<T | null> => {
|
||||
// Return cached data if fresh and not forced
|
||||
if (!force && !isStale()) {
|
||||
const cached = getCachedData()
|
||||
if (cached) return cached
|
||||
}
|
||||
|
||||
const result = await api.execute()
|
||||
|
||||
if (result) {
|
||||
setCacheData({
|
||||
data: result,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
[api, isStale, getCachedData]
|
||||
)
|
||||
|
||||
const clearCache = useCallback(() => {
|
||||
setCacheData(null)
|
||||
api.reset()
|
||||
}, [api])
|
||||
|
||||
return {
|
||||
data: api.data || getCachedData(),
|
||||
loading: api.loading,
|
||||
error: api.error,
|
||||
fetchData,
|
||||
clearCache,
|
||||
get isStale() {
|
||||
return isStale()
|
||||
},
|
||||
}
|
||||
}
|
||||
210
frontend/app/hooks/useTask.test.ts
Normal file
210
frontend/app/hooks/useTask.test.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { useTask } from './useTask'
|
||||
import { Task, TaskStatus, UpdateTaskRequest } from '~/types/task'
|
||||
import { apiClient } from '~/services/api'
|
||||
|
||||
// Mock the API client
|
||||
vi.mock('~/services/api', () => ({
|
||||
apiClient: {
|
||||
getTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockApiClient = apiClient as {
|
||||
getTask: ReturnType<typeof vi.fn>
|
||||
updateTask: ReturnType<typeof vi.fn>
|
||||
deleteTask: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
// Sample task data
|
||||
const mockTask: Task = {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
title: 'Test Task',
|
||||
description: 'Test Description',
|
||||
status: TaskStatus.Todo,
|
||||
created_at: '2023-01-01T00:00:00Z',
|
||||
updated_at: '2023-01-01T00:00:00Z',
|
||||
completed_at: null,
|
||||
}
|
||||
|
||||
describe('useTask', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() => useTask())
|
||||
|
||||
expect(result.current.task).toBeNull()
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
describe('getTask', () => {
|
||||
it('should fetch task successfully', async () => {
|
||||
mockApiClient.getTask.mockResolvedValueOnce(mockTask)
|
||||
|
||||
const { result } = renderHook(() => useTask())
|
||||
|
||||
act(() => {
|
||||
result.current.getTask(mockTask.id)
|
||||
})
|
||||
|
||||
expect(result.current.loading).toBe(true)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.task).toEqual(mockTask)
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(mockApiClient.getTask).toHaveBeenCalledWith(mockTask.id)
|
||||
})
|
||||
|
||||
it('should handle fetch errors', async () => {
|
||||
const errorMessage = 'Task not found'
|
||||
mockApiClient.getTask.mockRejectedValueOnce({ message: errorMessage })
|
||||
|
||||
const { result } = renderHook(() => useTask())
|
||||
|
||||
act(() => {
|
||||
result.current.getTask(mockTask.id)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.task).toBeNull()
|
||||
expect(result.current.error).toBe(errorMessage)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateTask', () => {
|
||||
it('should update task with optimistic update', async () => {
|
||||
const updateData: UpdateTaskRequest = {
|
||||
title: 'Updated Task',
|
||||
status: TaskStatus.Done,
|
||||
}
|
||||
const updatedTask = { ...mockTask, ...updateData }
|
||||
|
||||
mockApiClient.getTask.mockResolvedValueOnce(mockTask)
|
||||
mockApiClient.updateTask.mockResolvedValueOnce(updatedTask)
|
||||
|
||||
const { result } = renderHook(() => useTask())
|
||||
|
||||
// Set initial task
|
||||
await act(async () => {
|
||||
await result.current.getTask(mockTask.id)
|
||||
})
|
||||
|
||||
let updateResult: Task | null = null
|
||||
|
||||
await act(async () => {
|
||||
updateResult = await result.current.updateTask(mockTask.id, updateData)
|
||||
})
|
||||
|
||||
expect(updateResult).toEqual(updatedTask)
|
||||
expect(mockApiClient.updateTask).toHaveBeenCalledWith(
|
||||
mockTask.id,
|
||||
updateData
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle update errors and revert optimistic update', async () => {
|
||||
const updateData: UpdateTaskRequest = { status: TaskStatus.Done }
|
||||
const errorMessage = 'Update failed'
|
||||
|
||||
// Setup initial task
|
||||
mockApiClient.getTask.mockResolvedValueOnce(mockTask)
|
||||
|
||||
const { result } = renderHook(() => useTask())
|
||||
|
||||
// Set initial task state
|
||||
await act(async () => {
|
||||
await result.current.getTask(mockTask.id)
|
||||
})
|
||||
|
||||
expect(result.current.task).toEqual(mockTask)
|
||||
|
||||
// Mock update failure and revert call
|
||||
mockApiClient.updateTask.mockRejectedValueOnce({ message: errorMessage })
|
||||
mockApiClient.getTask.mockResolvedValueOnce(mockTask)
|
||||
|
||||
let updateResult: Task | null = null
|
||||
|
||||
await act(async () => {
|
||||
updateResult = await result.current.updateTask(mockTask.id, updateData)
|
||||
})
|
||||
|
||||
expect(updateResult).toBeNull()
|
||||
expect(result.current.error).toBe(errorMessage)
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteTask', () => {
|
||||
it('should delete task successfully', async () => {
|
||||
mockApiClient.deleteTask.mockResolvedValueOnce(undefined)
|
||||
|
||||
const { result } = renderHook(() => useTask())
|
||||
|
||||
// Set initial task
|
||||
await act(async () => {
|
||||
mockApiClient.getTask.mockResolvedValueOnce(mockTask)
|
||||
await result.current.getTask(mockTask.id)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteTask(mockTask.id)
|
||||
})
|
||||
|
||||
expect(result.current.task).toBeNull()
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(mockApiClient.deleteTask).toHaveBeenCalledWith(mockTask.id)
|
||||
})
|
||||
|
||||
it('should handle delete errors', async () => {
|
||||
const errorMessage = 'Delete failed'
|
||||
mockApiClient.deleteTask.mockRejectedValueOnce({ message: errorMessage })
|
||||
|
||||
const { result } = renderHook(() => useTask())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteTask(mockTask.id)
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe(errorMessage)
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearError', () => {
|
||||
it('should clear error state', async () => {
|
||||
mockApiClient.getTask.mockRejectedValueOnce({ message: 'Test error' })
|
||||
|
||||
const { result } = renderHook(() => useTask())
|
||||
|
||||
// Trigger an error
|
||||
await act(async () => {
|
||||
await result.current.getTask(mockTask.id)
|
||||
})
|
||||
|
||||
expect(result.current.error).toBeTruthy()
|
||||
|
||||
// Clear the error
|
||||
act(() => {
|
||||
result.current.clearError()
|
||||
})
|
||||
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
114
frontend/app/hooks/useTask.ts
Normal file
114
frontend/app/hooks/useTask.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { useState, useCallback } from 'react'
|
||||
import { Task, UpdateTaskRequest, ApiError } from '~/types/task'
|
||||
import { apiClient } from '~/services/api'
|
||||
|
||||
interface UseTaskState {
|
||||
task: Task | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface UseTaskActions {
|
||||
getTask: (id: string) => Promise<void>
|
||||
updateTask: (id: string, data: UpdateTaskRequest) => Promise<Task | null>
|
||||
deleteTask: (id: string) => Promise<void>
|
||||
clearError: () => void
|
||||
}
|
||||
|
||||
export function useTask(): UseTaskState & UseTaskActions {
|
||||
const [state, setState] = useState<UseTaskState>({
|
||||
task: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setState(prev => ({ ...prev, error: null }))
|
||||
}, [])
|
||||
|
||||
const getTask = useCallback(async (id: string) => {
|
||||
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||
|
||||
try {
|
||||
const task = await apiClient.getTask(id)
|
||||
setState(prev => ({ ...prev, task, loading: false }))
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: apiError.message,
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateTask = useCallback(
|
||||
async (id: string, data: UpdateTaskRequest): Promise<Task | null> => {
|
||||
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||
|
||||
try {
|
||||
// Optimistic update
|
||||
if (state.task && state.task.id === id) {
|
||||
const optimisticTask: Task = {
|
||||
...state.task,
|
||||
...data,
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
setState(prev => ({ ...prev, task: optimisticTask }))
|
||||
}
|
||||
|
||||
const updatedTask = await apiClient.updateTask(id, data)
|
||||
setState(prev => ({ ...prev, task: updatedTask, loading: false }))
|
||||
return updatedTask
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: apiError.message,
|
||||
}))
|
||||
|
||||
// Revert optimistic update on error
|
||||
if (state.task && state.task.id === id) {
|
||||
try {
|
||||
const originalTask = await apiClient.getTask(id)
|
||||
setState(prev => ({ ...prev, task: originalTask }))
|
||||
} catch {
|
||||
// If we can't revert, just keep the optimistic state
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
},
|
||||
[state.task]
|
||||
)
|
||||
|
||||
const deleteTask = useCallback(async (id: string) => {
|
||||
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||
|
||||
try {
|
||||
await apiClient.deleteTask(id)
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
task: prev.task?.id === id ? null : prev.task,
|
||||
loading: false,
|
||||
}))
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: apiError.message,
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
...state,
|
||||
getTask,
|
||||
updateTask,
|
||||
deleteTask,
|
||||
clearError,
|
||||
}
|
||||
}
|
||||
317
frontend/app/hooks/useTasks.test.ts
Normal file
317
frontend/app/hooks/useTasks.test.ts
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { useTasks } from './useTasks'
|
||||
import { Task, TaskStatus, CreateTaskRequest } from '~/types/task'
|
||||
import { apiClient } from '~/services/api'
|
||||
|
||||
// Mock the API client
|
||||
vi.mock('~/services/api', () => ({
|
||||
apiClient: {
|
||||
listTasks: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockApiClient = apiClient as {
|
||||
listTasks: ReturnType<typeof vi.fn>
|
||||
createTask: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
// Sample task data
|
||||
const mockTasks: Task[] = [
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
title: 'Test Task 1',
|
||||
description: 'Test Description 1',
|
||||
status: TaskStatus.Todo,
|
||||
created_at: '2023-01-01T00:00:00Z',
|
||||
updated_at: '2023-01-01T00:00:00Z',
|
||||
completed_at: null,
|
||||
},
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440001',
|
||||
title: 'Test Task 2',
|
||||
description: 'Test Description 2',
|
||||
status: TaskStatus.Done,
|
||||
created_at: '2023-01-01T01:00:00Z',
|
||||
updated_at: '2023-01-01T01:00:00Z',
|
||||
completed_at: '2023-01-01T02:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440002',
|
||||
title: 'Test Task 3',
|
||||
description: null,
|
||||
status: TaskStatus.Backlog,
|
||||
created_at: '2023-01-01T02:00:00Z',
|
||||
updated_at: '2023-01-01T02:00:00Z',
|
||||
completed_at: null,
|
||||
},
|
||||
]
|
||||
|
||||
describe('useTasks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
expect(result.current.tasks).toEqual([])
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(result.current.lastFetch).toBeNull()
|
||||
})
|
||||
|
||||
it('should auto-fetch tasks on mount by default', async () => {
|
||||
mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
|
||||
|
||||
renderHook(() => useTasks())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiClient.listTasks).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not auto-fetch when disabled', () => {
|
||||
renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
expect(mockApiClient.listTasks).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('fetchTasks', () => {
|
||||
it('should fetch tasks successfully', async () => {
|
||||
mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
|
||||
|
||||
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchTasks()
|
||||
})
|
||||
|
||||
expect(result.current.tasks).toEqual(mockTasks)
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(result.current.lastFetch).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('should handle fetch errors', async () => {
|
||||
const errorMessage = 'Fetch failed'
|
||||
mockApiClient.listTasks.mockRejectedValueOnce({ message: errorMessage })
|
||||
|
||||
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchTasks()
|
||||
})
|
||||
|
||||
expect(result.current.tasks).toEqual([])
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.error).toBe(errorMessage)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createTask', () => {
|
||||
it('should create task successfully', async () => {
|
||||
const newTaskData: CreateTaskRequest = {
|
||||
title: 'New Task',
|
||||
description: 'New Description',
|
||||
}
|
||||
const newTask: Task = {
|
||||
id: 'new-task-id',
|
||||
title: newTaskData.title,
|
||||
description: newTaskData.description || null,
|
||||
status: TaskStatus.Todo,
|
||||
created_at: '2023-01-01T03:00:00Z',
|
||||
updated_at: '2023-01-01T03:00:00Z',
|
||||
completed_at: null,
|
||||
}
|
||||
|
||||
mockApiClient.createTask.mockResolvedValueOnce(newTask)
|
||||
|
||||
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
// Set initial tasks
|
||||
await act(async () => {
|
||||
mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
|
||||
await result.current.fetchTasks()
|
||||
})
|
||||
|
||||
let createResult: Task | null = null
|
||||
|
||||
await act(async () => {
|
||||
createResult = await result.current.createTask(newTaskData)
|
||||
})
|
||||
|
||||
expect(createResult).toEqual(newTask)
|
||||
expect(result.current.tasks[0]).toEqual(newTask) // Should be first in list
|
||||
expect(result.current.tasks).toHaveLength(mockTasks.length + 1)
|
||||
expect(mockApiClient.createTask).toHaveBeenCalledWith(newTaskData)
|
||||
})
|
||||
|
||||
it('should handle create errors', async () => {
|
||||
const newTaskData: CreateTaskRequest = { title: '' }
|
||||
const errorMessage = 'Title must not be empty'
|
||||
|
||||
mockApiClient.createTask.mockRejectedValueOnce({ message: errorMessage })
|
||||
|
||||
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
let createResult: Task | null = null
|
||||
|
||||
await act(async () => {
|
||||
createResult = await result.current.createTask(newTaskData)
|
||||
})
|
||||
|
||||
expect(createResult).toBeNull()
|
||||
expect(result.current.error).toBe(errorMessage)
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshTasks', () => {
|
||||
it('should refresh tasks without loading state when tasks exist', async () => {
|
||||
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
// Set initial tasks
|
||||
await act(async () => {
|
||||
mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
|
||||
await result.current.fetchTasks()
|
||||
})
|
||||
|
||||
// Refresh with updated tasks
|
||||
const updatedTasks = [...mockTasks, { ...mockTasks[0], id: 'new-id' }]
|
||||
mockApiClient.listTasks.mockResolvedValueOnce(updatedTasks)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshTasks()
|
||||
})
|
||||
|
||||
expect(result.current.tasks).toEqual(updatedTasks)
|
||||
})
|
||||
|
||||
it('should show loading state when no tasks exist', async () => {
|
||||
mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
|
||||
|
||||
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
act(() => {
|
||||
result.current.refreshTasks()
|
||||
})
|
||||
|
||||
expect(result.current.loading).toBe(true)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('utility functions', () => {
|
||||
beforeEach(async () => {
|
||||
mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
|
||||
})
|
||||
|
||||
it('should get task by ID', async () => {
|
||||
const { result } = renderHook(() => useTasks())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(mockTasks.length)
|
||||
})
|
||||
|
||||
const foundTask = result.current.getTaskById(mockTasks[0].id)
|
||||
expect(foundTask).toEqual(mockTasks[0])
|
||||
|
||||
const notFoundTask = result.current.getTaskById('nonexistent-id')
|
||||
expect(notFoundTask).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should filter tasks by status', async () => {
|
||||
const { result } = renderHook(() => useTasks())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(mockTasks.length)
|
||||
})
|
||||
|
||||
const todoTasks = result.current.filterTasksByStatus(TaskStatus.Todo)
|
||||
expect(todoTasks).toHaveLength(1)
|
||||
expect(todoTasks[0].status).toBe(TaskStatus.Todo)
|
||||
|
||||
const doneTasks = result.current.filterTasksByStatus(TaskStatus.Done)
|
||||
expect(doneTasks).toHaveLength(1)
|
||||
expect(doneTasks[0].status).toBe(TaskStatus.Done)
|
||||
|
||||
const backlogTasks = result.current.filterTasksByStatus(
|
||||
TaskStatus.Backlog
|
||||
)
|
||||
expect(backlogTasks).toHaveLength(1)
|
||||
expect(backlogTasks[0].status).toBe(TaskStatus.Backlog)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearError', () => {
|
||||
it('should clear error state', async () => {
|
||||
mockApiClient.listTasks.mockRejectedValueOnce({ message: 'Test error' })
|
||||
|
||||
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
// Trigger an error
|
||||
await act(async () => {
|
||||
await result.current.fetchTasks()
|
||||
})
|
||||
|
||||
expect(result.current.error).toBeTruthy()
|
||||
|
||||
// Clear the error
|
||||
act(() => {
|
||||
result.current.clearError()
|
||||
})
|
||||
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('refresh interval', () => {
|
||||
it('should set up refresh interval', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const updatedTasks = [...mockTasks, { ...mockTasks[0], id: 'new-id' }]
|
||||
|
||||
mockApiClient.listTasks
|
||||
.mockResolvedValueOnce(mockTasks)
|
||||
.mockResolvedValueOnce(updatedTasks)
|
||||
.mockResolvedValue(updatedTasks) // Handle any extra calls
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useTasks({ refreshInterval: 1000, autoFetch: false })
|
||||
)
|
||||
|
||||
// Manual initial fetch
|
||||
await act(async () => {
|
||||
await result.current.fetchTasks()
|
||||
})
|
||||
|
||||
expect(result.current.tasks).toHaveLength(mockTasks.length)
|
||||
|
||||
const initialCallCount = mockApiClient.listTasks.mock.calls.length
|
||||
|
||||
// Advance timer to trigger refresh
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
})
|
||||
|
||||
// Should have made at least one more call
|
||||
expect(mockApiClient.listTasks.mock.calls.length).toBeGreaterThan(
|
||||
initialCallCount
|
||||
)
|
||||
expect(result.current.tasks).toHaveLength(updatedTasks.length)
|
||||
|
||||
unmount()
|
||||
vi.useRealTimers()
|
||||
}, 10000)
|
||||
})
|
||||
})
|
||||
160
frontend/app/hooks/useTasks.ts
Normal file
160
frontend/app/hooks/useTasks.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { Task, CreateTaskRequest, TaskStatus, ApiError } from '~/types/task'
|
||||
import { apiClient } from '~/services/api'
|
||||
|
||||
interface UseTasksState {
|
||||
tasks: Task[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
lastFetch: Date | null
|
||||
}
|
||||
|
||||
interface UseTasksActions {
|
||||
fetchTasks: () => Promise<void>
|
||||
createTask: (data: CreateTaskRequest) => Promise<Task | null>
|
||||
refreshTasks: () => Promise<void>
|
||||
clearError: () => void
|
||||
getTaskById: (id: string) => Task | undefined
|
||||
filterTasksByStatus: (status: TaskStatus) => Task[]
|
||||
}
|
||||
|
||||
interface UseTasksOptions {
|
||||
autoFetch?: boolean
|
||||
refreshInterval?: number
|
||||
}
|
||||
|
||||
export function useTasks(
|
||||
options: UseTasksOptions = {}
|
||||
): UseTasksState & UseTasksActions {
|
||||
const { autoFetch = true, refreshInterval } = options
|
||||
|
||||
const [state, setState] = useState<UseTasksState>({
|
||||
tasks: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastFetch: null,
|
||||
})
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setState(prev => ({ ...prev, error: null }))
|
||||
}, [])
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||
|
||||
try {
|
||||
const tasks = await apiClient.listTasks()
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
tasks,
|
||||
loading: false,
|
||||
lastFetch: new Date(),
|
||||
}))
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: apiError.message,
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const createTask = useCallback(
|
||||
async (data: CreateTaskRequest): Promise<Task | null> => {
|
||||
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||
|
||||
try {
|
||||
const newTask = await apiClient.createTask(data)
|
||||
|
||||
// Add the new task to the beginning of the list (most recent first)
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
tasks: [newTask, ...prev.tasks],
|
||||
loading: false,
|
||||
}))
|
||||
|
||||
return newTask
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: apiError.message,
|
||||
}))
|
||||
return null
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const refreshTasks = useCallback(async () => {
|
||||
// Force refresh without showing loading state if tasks already exist
|
||||
const showLoading = state.tasks.length === 0
|
||||
|
||||
if (showLoading) {
|
||||
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||
} else {
|
||||
setState(prev => ({ ...prev, error: null }))
|
||||
}
|
||||
|
||||
try {
|
||||
const tasks = await apiClient.listTasks()
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
tasks,
|
||||
loading: false,
|
||||
lastFetch: new Date(),
|
||||
}))
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: apiError.message,
|
||||
}))
|
||||
}
|
||||
}, [state.tasks])
|
||||
|
||||
const getTaskById = useCallback(
|
||||
(id: string): Task | undefined => {
|
||||
return state.tasks.find(task => task.id === id)
|
||||
},
|
||||
[state.tasks]
|
||||
)
|
||||
|
||||
const filterTasksByStatus = useCallback(
|
||||
(status: TaskStatus): Task[] => {
|
||||
return state.tasks.filter(task => task.status === status)
|
||||
},
|
||||
[state.tasks]
|
||||
)
|
||||
|
||||
// Auto-fetch tasks on mount
|
||||
useEffect(() => {
|
||||
if (autoFetch) {
|
||||
fetchTasks()
|
||||
}
|
||||
}, [autoFetch, fetchTasks])
|
||||
|
||||
// Set up refresh interval if specified
|
||||
useEffect(() => {
|
||||
if (!refreshInterval) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
refreshTasks()
|
||||
}, refreshInterval)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [refreshInterval, refreshTasks])
|
||||
|
||||
return {
|
||||
...state,
|
||||
fetchTasks,
|
||||
createTask,
|
||||
refreshTasks,
|
||||
clearError,
|
||||
getTaskById,
|
||||
filterTasksByStatus,
|
||||
}
|
||||
}
|
||||
271
frontend/app/services/api.test.ts
Normal file
271
frontend/app/services/api.test.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { apiClient } from './api'
|
||||
import {
|
||||
Task,
|
||||
TaskStatus,
|
||||
CreateTaskRequest,
|
||||
UpdateTaskRequest,
|
||||
} from '~/types/task'
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = vi.fn()
|
||||
global.fetch = mockFetch
|
||||
|
||||
// Sample task data for testing
|
||||
const mockTask: Task = {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
title: 'Test Task',
|
||||
description: 'Test Description',
|
||||
status: TaskStatus.Todo,
|
||||
created_at: '2023-01-01T00:00:00Z',
|
||||
updated_at: '2023-01-01T00:00:00Z',
|
||||
completed_at: null,
|
||||
}
|
||||
|
||||
const mockTasks: Task[] = [
|
||||
mockTask,
|
||||
{
|
||||
...mockTask,
|
||||
id: '550e8400-e29b-41d4-a716-446655440001',
|
||||
title: 'Another Task',
|
||||
status: TaskStatus.Done,
|
||||
completed_at: '2023-01-01T01:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
describe('API Client', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockClear()
|
||||
// Silence console.log during tests
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('listTasks', () => {
|
||||
it('should fetch all tasks successfully', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: () => Promise.resolve(mockTasks),
|
||||
})
|
||||
|
||||
const result = await apiClient.listTasks()
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/tasks', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
expect(result).toEqual(mockTasks)
|
||||
})
|
||||
|
||||
it('should handle empty task list', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: () => Promise.resolve([]),
|
||||
})
|
||||
|
||||
const result = await apiClient.listTasks()
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should throw error on failed request', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: () => Promise.resolve({ message: 'Server error' }),
|
||||
})
|
||||
|
||||
await expect(apiClient.listTasks()).rejects.toThrow('Server error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTask', () => {
|
||||
it('should fetch single task successfully', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: () => Promise.resolve(mockTask),
|
||||
})
|
||||
|
||||
const result = await apiClient.getTask(mockTask.id)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(`/api/tasks/${mockTask.id}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
expect(result).toEqual(mockTask)
|
||||
})
|
||||
|
||||
it('should handle task not found', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
json: () => Promise.resolve({ message: 'Task not found' }),
|
||||
})
|
||||
|
||||
await expect(apiClient.getTask('nonexistent-id')).rejects.toThrow(
|
||||
'Task not found'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createTask', () => {
|
||||
it('should create task successfully', async () => {
|
||||
const newTaskData: CreateTaskRequest = {
|
||||
title: 'New Task',
|
||||
description: 'New Description',
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 201,
|
||||
statusText: 'Created',
|
||||
json: () => Promise.resolve(mockTask),
|
||||
})
|
||||
|
||||
const result = await apiClient.createTask(newTaskData)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newTaskData),
|
||||
})
|
||||
expect(result).toEqual(mockTask)
|
||||
})
|
||||
|
||||
it('should handle validation errors', async () => {
|
||||
const invalidData: CreateTaskRequest = {
|
||||
title: '',
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 422,
|
||||
statusText: 'Unprocessable Entity',
|
||||
json: () => Promise.resolve({ message: 'Title must not be empty' }),
|
||||
})
|
||||
|
||||
await expect(apiClient.createTask(invalidData)).rejects.toThrow(
|
||||
'Title must not be empty'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateTask', () => {
|
||||
it('should update task successfully', async () => {
|
||||
const updateData: UpdateTaskRequest = {
|
||||
title: 'Updated Task',
|
||||
status: TaskStatus.Done,
|
||||
}
|
||||
|
||||
const updatedTask = { ...mockTask, ...updateData }
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: () => Promise.resolve(updatedTask),
|
||||
})
|
||||
|
||||
const result = await apiClient.updateTask(mockTask.id, updateData)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(`/api/tasks/${mockTask.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updateData),
|
||||
})
|
||||
expect(result).toEqual(updatedTask)
|
||||
})
|
||||
|
||||
it('should handle partial updates', async () => {
|
||||
const updateData: UpdateTaskRequest = {
|
||||
status: TaskStatus.Done,
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: () => Promise.resolve({ ...mockTask, status: TaskStatus.Done }),
|
||||
})
|
||||
|
||||
const result = await apiClient.updateTask(mockTask.id, updateData)
|
||||
|
||||
expect(result.status).toBe(TaskStatus.Done)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteTask', () => {
|
||||
it('should delete task successfully', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 204,
|
||||
statusText: 'No Content',
|
||||
})
|
||||
|
||||
const result = await apiClient.deleteTask(mockTask.id)
|
||||
expect(result).toBeNull()
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(`/api/tasks/${mockTask.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle delete errors', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
json: () => Promise.resolve({ message: 'Task not found' }),
|
||||
})
|
||||
|
||||
await expect(apiClient.deleteTask('nonexistent-id')).rejects.toThrow(
|
||||
'Task not found'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle network errors', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
await expect(apiClient.listTasks()).rejects.toThrow('Network error')
|
||||
})
|
||||
|
||||
it('should handle malformed JSON responses', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: () => Promise.reject(new Error('Invalid JSON')),
|
||||
})
|
||||
|
||||
await expect(apiClient.listTasks()).rejects.toThrow(
|
||||
'HTTP 500: Internal Server Error'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle responses with no error message', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
json: () => Promise.resolve({}),
|
||||
})
|
||||
|
||||
await expect(apiClient.listTasks()).rejects.toThrow(
|
||||
'HTTP 400: Bad Request'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
113
frontend/app/services/api.ts
Normal file
113
frontend/app/services/api.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import {
|
||||
Task,
|
||||
CreateTaskRequest,
|
||||
UpdateTaskRequest,
|
||||
ApiError,
|
||||
} from '~/types/task'
|
||||
|
||||
const API_BASE_URL = '/api'
|
||||
|
||||
class ApiClient {
|
||||
private async fetchWrapper<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const url = `${API_BASE_URL}${endpoint}`
|
||||
|
||||
const config: RequestInit = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`API Request: ${config.method || 'GET'} ${url}`, {
|
||||
body: config.body,
|
||||
headers: config.headers,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config)
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`API Response: ${response.status} ${response.statusText}`, {
|
||||
url,
|
||||
status: response.status,
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `HTTP ${response.status}: ${response.statusText}`
|
||||
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
errorMessage = errorData.message || errorMessage
|
||||
} catch {
|
||||
// If JSON parsing fails, use the default error message
|
||||
}
|
||||
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null as T
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('API Response Data:', data)
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
const apiError: ApiError = {
|
||||
message:
|
||||
error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
status:
|
||||
error instanceof Error && 'status' in error
|
||||
? (error as { status?: number }).status
|
||||
: undefined,
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('API Error:', apiError)
|
||||
}
|
||||
|
||||
throw apiError
|
||||
}
|
||||
}
|
||||
|
||||
async listTasks(): Promise<Task[]> {
|
||||
return this.fetchWrapper<Task[]>('/tasks')
|
||||
}
|
||||
|
||||
async getTask(id: string): Promise<Task> {
|
||||
return this.fetchWrapper<Task>(`/tasks/${id}`)
|
||||
}
|
||||
|
||||
async createTask(data: CreateTaskRequest): Promise<Task> {
|
||||
return this.fetchWrapper<Task>('/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
async updateTask(id: string, data: UpdateTaskRequest): Promise<Task> {
|
||||
return this.fetchWrapper<Task>(`/tasks/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
async deleteTask(id: string): Promise<void> {
|
||||
return this.fetchWrapper<void>(`/tasks/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient()
|
||||
35
frontend/app/types/task.ts
Normal file
35
frontend/app/types/task.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
export enum TaskStatus {
|
||||
Todo = 'todo',
|
||||
Done = 'done',
|
||||
Backlog = 'backlog',
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string
|
||||
title: string
|
||||
description: string | null
|
||||
status: TaskStatus
|
||||
created_at: string
|
||||
updated_at: string
|
||||
completed_at: string | null
|
||||
}
|
||||
|
||||
export interface CreateTaskRequest {
|
||||
title: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface UpdateTaskRequest {
|
||||
title?: string
|
||||
description?: string
|
||||
status?: TaskStatus
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
message: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
export interface TaskListResponse {
|
||||
tasks: Task[]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue