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)
})
})

View 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()
},
}
}

View 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()
})
})
})

View 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,
}
}

View 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)
})
})

View 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,
}
}

View 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'
)
})
})
})

View 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()

View 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[]
}

View file

@ -75,14 +75,14 @@ captains-log/
## Phase 2: Core API Integration (Days 3-4) ## Phase 2: Core API Integration (Days 3-4)
### Task 2.1: Define TypeScript Types ### Task 2.1: Define TypeScript Types
- [ ] **File**: `frontend/src/types/task.ts` - [x] **File**: `frontend/src/types/task.ts`
- Create Task interface matching backend TaskModel - Create Task interface matching backend TaskModel
- Add TaskStatus enum (Todo, Done, Backlog) - Add TaskStatus enum (Todo, Done, Backlog)
- Include API response types and error types - Include API response types and error types
- **Expected outcome**: Type definitions compile without errors - **Expected outcome**: Type definitions compile without errors
### Task 2.2: Backend API Client ### Task 2.2: Backend API Client
- [ ] **File**: `frontend/src/services/api.ts` - [x] **File**: `frontend/src/services/api.ts`
- Implement API client with fetch wrapper - Implement API client with fetch wrapper
- Add all task endpoints: GET, POST, PUT, DELETE /api/tasks - Add all task endpoints: GET, POST, PUT, DELETE /api/tasks
- Include error handling and response parsing - Include error handling and response parsing
@ -90,7 +90,7 @@ captains-log/
- **Expected outcome**: API client can communicate with backend - **Expected outcome**: API client can communicate with backend
### Task 2.3: Custom React Hooks for API ### Task 2.3: Custom React Hooks for API
- [ ] **Files**: `frontend/src/hooks/useTask.ts`, `frontend/src/hooks/useTasks.ts` - [x] **Files**: `frontend/src/hooks/useTask.ts`, `frontend/src/hooks/useTasks.ts`
- Create useTask hook for single task operations (get, update, delete) - Create useTask hook for single task operations (get, update, delete)
- Create useTasks hook for task list operations (list, create) - Create useTasks hook for task list operations (list, create)
- Include loading states, error handling, and optimistic updates - Include loading states, error handling, and optimistic updates
@ -98,7 +98,7 @@ captains-log/
- **Expected outcome**: Hooks provide clean API for components - **Expected outcome**: Hooks provide clean API for components
### Task 2.4: API Integration Tests ### Task 2.4: API Integration Tests
- [ ] **File**: `frontend/tests/api.test.ts` - [x] **File**: `frontend/tests/api.test.ts`
- Test API client with mock responses - Test API client with mock responses
- Test custom hooks with mock API calls - Test custom hooks with mock API calls
- Test error handling scenarios - Test error handling scenarios