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