import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useTaskPolling } from './useTaskPolling'; // ── mock the api module ──────────────────────────────────────────────────── vi.mock('@/services/api', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getTaskStatus: vi.fn(), }; }); import { getTaskStatus } from '@/services/api'; const mockGetStatus = vi.mocked(getTaskStatus); // ── tests ────────────────────────────────────────────────────────────────── describe('useTaskPolling', () => { beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); // ── initial state ────────────────────────────────────────────────────── it('starts idle when taskId is null', () => { const { result } = renderHook(() => useTaskPolling({ taskId: null }) ); expect(result.current.isPolling).toBe(false); expect(result.current.status).toBeNull(); expect(result.current.result).toBeNull(); expect(result.current.error).toBeNull(); }); // ── begins polling immediately ───────────────────────────────────────── it('polls immediately when taskId is provided', async () => { mockGetStatus.mockResolvedValue({ task_id: 'task-1', state: 'PENDING', }); const { result } = renderHook(() => useTaskPolling({ taskId: 'task-1', intervalMs: 1000 }) ); // Advance just enough to let the immediate poll() Promise resolve, // but NOT enough to trigger the setInterval tick (< 1000ms). await act(async () => { await vi.advanceTimersByTimeAsync(50); }); expect(mockGetStatus).toHaveBeenCalledWith('task-1'); expect(result.current.isPolling).toBe(true); expect(result.current.status?.state).toBe('PENDING'); }); // ── polls at the configured interval ───────────────────────────────── it('polls again after the interval elapses', async () => { mockGetStatus.mockResolvedValue({ task_id: 'task-2', state: 'PROCESSING' }); renderHook(() => useTaskPolling({ taskId: 'task-2', intervalMs: 1500 }) ); await act(async () => { await vi.advanceTimersByTimeAsync(1500 * 3 + 100); // 3 intervals }); // At least 3 calls (initial + 3 interval ticks) expect(mockGetStatus.mock.calls.length).toBeGreaterThanOrEqual(3); }); // ── SUCCESS state ────────────────────────────────────────────────────── it('stops polling and sets result on SUCCESS with completed status', async () => { const taskResult = { status: 'completed' as const, download_url: '/api/download/task-3/output.pdf', filename: 'output.pdf', }; mockGetStatus.mockResolvedValueOnce({ task_id: 'task-3', state: 'PENDING' }); mockGetStatus.mockResolvedValueOnce({ task_id: 'task-3', state: 'SUCCESS', result: taskResult, }); const onComplete = vi.fn(); const { result } = renderHook(() => useTaskPolling({ taskId: 'task-3', intervalMs: 500, onComplete }) ); await act(async () => { await vi.advanceTimersByTimeAsync(1200); }); expect(result.current.isPolling).toBe(false); expect(result.current.result).toEqual(taskResult); expect(result.current.error).toBeNull(); expect(onComplete).toHaveBeenCalledWith(taskResult); }); // ── SUCCESS with error in result ─────────────────────────────────────── it('sets error when SUCCESS result contains status "failed"', async () => { mockGetStatus.mockResolvedValueOnce({ task_id: 'task-4', state: 'SUCCESS', result: { status: 'failed', error: 'Ghostscript not found.' }, }); const onError = vi.fn(); const { result } = renderHook(() => useTaskPolling({ taskId: 'task-4', intervalMs: 500, onError }) ); await act(async () => { await vi.runAllTimersAsync(); }); expect(result.current.isPolling).toBe(false); expect(result.current.result).toBeNull(); expect(result.current.error).toBe('Ghostscript not found.'); expect(onError).toHaveBeenCalledWith('Ghostscript not found.'); }); it('prefers a structured task error payload when SUCCESS contains a failed result', async () => { mockGetStatus.mockResolvedValueOnce({ task_id: 'task-4b', state: 'SUCCESS', error: { error_code: 'OPENROUTER_MISSING_API_KEY', user_message: 'AI features are temporarily unavailable. Our team has been notified.', task_id: 'task-4b', }, result: { status: 'failed' }, }); const onError = vi.fn(); const { result } = renderHook(() => useTaskPolling({ taskId: 'task-4b', intervalMs: 500, onError }) ); await act(async () => { await vi.runAllTimersAsync(); }); expect(result.current.isPolling).toBe(false); expect(result.current.result).toBeNull(); expect(result.current.error).toBe('AI features are temporarily unavailable. Our team has been notified.'); expect(onError).toHaveBeenCalledWith('AI features are temporarily unavailable. Our team has been notified.'); }); // ── FAILURE state ────────────────────────────────────────────────────── it('stops polling and sets error on FAILURE state', async () => { mockGetStatus.mockResolvedValueOnce({ task_id: 'task-5', state: 'FAILURE', error: 'Worker crashed.', }); const onError = vi.fn(); const { result } = renderHook(() => useTaskPolling({ taskId: 'task-5', intervalMs: 500, onError }) ); await act(async () => { await vi.runAllTimersAsync(); }); expect(result.current.isPolling).toBe(false); expect(result.current.error).toBe('Worker crashed.'); expect(onError).toHaveBeenCalledWith('Worker crashed.'); }); it('normalizes a structured FAILURE payload into a safe string message', async () => { mockGetStatus.mockResolvedValueOnce({ task_id: 'task-5b', state: 'FAILURE', error: { error_code: 'TASK_FAILURE', user_message: 'Task processing failed. Please retry.', task_id: 'task-5b', }, }); const onError = vi.fn(); const { result } = renderHook(() => useTaskPolling({ taskId: 'task-5b', intervalMs: 500, onError }) ); await act(async () => { await vi.runAllTimersAsync(); }); expect(result.current.isPolling).toBe(false); expect(result.current.error).toBe('Task processing failed. Please retry.'); expect(onError).toHaveBeenCalledWith('Task processing failed. Please retry.'); }); // ── network error ────────────────────────────────────────────────────── it('stops polling and sets error on network/API exception', async () => { mockGetStatus.mockRejectedValueOnce(new Error('Network error.')); const onError = vi.fn(); const { result } = renderHook(() => useTaskPolling({ taskId: 'task-6', intervalMs: 500, onError }) ); await act(async () => { await vi.runAllTimersAsync(); }); expect(result.current.isPolling).toBe(false); expect(result.current.error).toBe('Network error.'); expect(onError).toHaveBeenCalledWith('Network error.'); }); // ── manual stopPolling ───────────────────────────────────────────────── it('stopPolling immediately halts further requests', async () => { mockGetStatus.mockResolvedValue({ task_id: 'task-7', state: 'PROCESSING' }); const { result } = renderHook(() => useTaskPolling({ taskId: 'task-7', intervalMs: 500 }) ); // Let one poll happen await act(async () => { await vi.advanceTimersByTimeAsync(100); }); act(() => { result.current.stopPolling(); }); const callsAfterStop = mockGetStatus.mock.calls.length; // Advance time — no new calls should happen await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); expect(result.current.isPolling).toBe(false); expect(mockGetStatus.mock.calls.length).toBe(callsAfterStop); }); // ── taskId changes ───────────────────────────────────────────────────── it('resets state and restarts polling when taskId changes', async () => { mockGetStatus.mockResolvedValue({ task_id: 'task-new', state: 'PENDING' }); const { result, rerender } = renderHook( ({ taskId }: { taskId: string | null }) => useTaskPolling({ taskId, intervalMs: 500 }), { initialProps: { taskId: null as string | null } } ); expect(result.current.isPolling).toBe(false); // Provide a task id rerender({ taskId: 'task-new' }); await act(async () => { await vi.advanceTimersByTimeAsync(200); }); expect(result.current.isPolling).toBe(true); expect(mockGetStatus).toHaveBeenCalledWith('task-new'); }); // ── FAILURE with missing error message ──────────────────────────────── it('falls back to default error message when FAILURE has no error field', async () => { mockGetStatus.mockResolvedValueOnce({ task_id: 'task-8', state: 'FAILURE' }); const { result } = renderHook(() => useTaskPolling({ taskId: 'task-8', intervalMs: 500 }) ); await act(async () => { await vi.runAllTimersAsync(); }); expect(result.current.error).toBe('Processing failed. Please try again.'); }); });