diff --git a/frontend/app/hooks/useApi.test.ts b/frontend/app/hooks/useApi.test.ts new file mode 100644 index 0000000..e30aacf --- /dev/null +++ b/frontend/app/hooks/useApi.test.ts @@ -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) + }) +}) diff --git a/frontend/app/hooks/useApi.ts b/frontend/app/hooks/useApi.ts new file mode 100644 index 0000000..07a4f7c --- /dev/null +++ b/frontend/app/hooks/useApi.ts @@ -0,0 +1,227 @@ +import { useState, useCallback, useRef, useEffect } from 'react' +import { ApiError } from '~/types/task' + +interface UseApiState { + data: T | null + loading: boolean + error: string | null +} + +interface UseApiActions { + execute: (...args: unknown[]) => Promise + reset: () => void + clearError: () => void +} + +interface UseApiOptions { + immediate?: boolean + onSuccess?: (data: unknown) => void + onError?: (error: string) => void +} + +export function useApi( + apiFunction: (...args: unknown[]) => Promise, + options: UseApiOptions = {} +): UseApiState & UseApiActions { + const { immediate = false, onSuccess, onError } = options + + const [state, setState] = useState>({ + data: null, + loading: false, + error: null, + }) + + const mountedRef = useRef(true) + const abortControllerRef = useRef(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 => { + // 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( + submitFunction: (data: unknown) => Promise, + 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( + key: string, + fetchFunction: () => Promise, + 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 => { + // 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() + }, + } +} diff --git a/frontend/app/hooks/useTask.test.ts b/frontend/app/hooks/useTask.test.ts new file mode 100644 index 0000000..8b25096 --- /dev/null +++ b/frontend/app/hooks/useTask.test.ts @@ -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 + updateTask: ReturnType + deleteTask: ReturnType +} + +// 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() + }) + }) +}) diff --git a/frontend/app/hooks/useTask.ts b/frontend/app/hooks/useTask.ts new file mode 100644 index 0000000..661fdc7 --- /dev/null +++ b/frontend/app/hooks/useTask.ts @@ -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 + updateTask: (id: string, data: UpdateTaskRequest) => Promise + deleteTask: (id: string) => Promise + clearError: () => void +} + +export function useTask(): UseTaskState & UseTaskActions { + const [state, setState] = useState({ + 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 => { + 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, + } +} diff --git a/frontend/app/hooks/useTasks.test.ts b/frontend/app/hooks/useTasks.test.ts new file mode 100644 index 0000000..0c327b1 --- /dev/null +++ b/frontend/app/hooks/useTasks.test.ts @@ -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 + createTask: ReturnType +} + +// 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) + }) +}) diff --git a/frontend/app/hooks/useTasks.ts b/frontend/app/hooks/useTasks.ts new file mode 100644 index 0000000..05ab151 --- /dev/null +++ b/frontend/app/hooks/useTasks.ts @@ -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 + createTask: (data: CreateTaskRequest) => Promise + refreshTasks: () => Promise + 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({ + 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 => { + 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, + } +} diff --git a/frontend/app/services/api.test.ts b/frontend/app/services/api.test.ts new file mode 100644 index 0000000..7e34db9 --- /dev/null +++ b/frontend/app/services/api.test.ts @@ -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' + ) + }) + }) +}) diff --git a/frontend/app/services/api.ts b/frontend/app/services/api.ts new file mode 100644 index 0000000..6d2f4b1 --- /dev/null +++ b/frontend/app/services/api.ts @@ -0,0 +1,113 @@ +import { + Task, + CreateTaskRequest, + UpdateTaskRequest, + ApiError, +} from '~/types/task' + +const API_BASE_URL = '/api' + +class ApiClient { + private async fetchWrapper( + endpoint: string, + options: RequestInit = {} + ): Promise { + 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 { + return this.fetchWrapper('/tasks') + } + + async getTask(id: string): Promise { + return this.fetchWrapper(`/tasks/${id}`) + } + + async createTask(data: CreateTaskRequest): Promise { + return this.fetchWrapper('/tasks', { + method: 'POST', + body: JSON.stringify(data), + }) + } + + async updateTask(id: string, data: UpdateTaskRequest): Promise { + return this.fetchWrapper(`/tasks/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }) + } + + async deleteTask(id: string): Promise { + return this.fetchWrapper(`/tasks/${id}`, { + method: 'DELETE', + }) + } +} + +export const apiClient = new ApiClient() diff --git a/frontend/app/types/task.ts b/frontend/app/types/task.ts new file mode 100644 index 0000000..9641c8d --- /dev/null +++ b/frontend/app/types/task.ts @@ -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[] +} diff --git a/plan/01_CORE_MVP/frontend.md b/plan/01_CORE_MVP/frontend.md index 93f956c..993ef31 100644 --- a/plan/01_CORE_MVP/frontend.md +++ b/plan/01_CORE_MVP/frontend.md @@ -75,14 +75,14 @@ captains-log/ ## Phase 2: Core API Integration (Days 3-4) ### 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 - Add TaskStatus enum (Todo, Done, Backlog) - Include API response types and error types - **Expected outcome**: Type definitions compile without errors ### 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 - Add all task endpoints: GET, POST, PUT, DELETE /api/tasks - Include error handling and response parsing @@ -90,7 +90,7 @@ captains-log/ - **Expected outcome**: API client can communicate with backend ### 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 useTasks hook for task list operations (list, create) - Include loading states, error handling, and optimistic updates @@ -98,7 +98,7 @@ captains-log/ - **Expected outcome**: Hooks provide clean API for components ### 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 custom hooks with mock API calls - Test error handling scenarios