import React from 'react'; import { render, act, cleanup } from '@testing-library/react'; import '@testing-library/jest-dom'; import { useFetchFeed } from '../useFetchFeed'; // Helper to create a mock fetch response function mockJsonResponse( data: any, ok = true, status = 200 ): Promise { const partial: Partial = { ok, status, json: () => Promise.resolve(data) }; return Promise.resolve(partial as Response); } function mockErrorResponse(status: number): Promise { const partial: Partial = { ok: false, status, json: () => Promise.resolve({}) }; return Promise.resolve(partial as Response); } // Helper component that renders the hook and exposes state const HookRenderer: React.FC<{ hook: () => any; onState: (state: any) => void; }> = ({ hook, onState }) => { const state = hook(); React.useEffect(() => { onState(state); }); return null; }; // Each test uses a unique URL so the module-level cache doesn't interfere. let urlCounter = 0; const uniqueUrl = (label: string) => `https://api.test/${label}-${++urlCounter}`; describe('useFetchFeed', () => { let originalFetch: typeof global.fetch; beforeEach(() => { originalFetch = global.fetch; jest.useFakeTimers(); }); afterEach(() => { cleanup(); global.fetch = originalFetch; jest.useRealTimers(); jest.restoreAllMocks(); }); it('returns idle state when url is undefined', async () => { const states: any[] = []; await act(async () => { render( useFetchFeed(undefined)} onState={(s: any) => states.push({ ...s })} /> ); }); const last = states[states.length - 1]; expect(last.data).toBeUndefined(); expect(last.loading).toBe(false); expect(last.error).toBeUndefined(); }); it('fetches data and returns it', async () => { const mockData = { result: 'ok' }; global.fetch = jest.fn(() => mockJsonResponse(mockData)); const url = uniqueUrl('fetch'); const states: any[] = []; await act(async () => { render( useFetchFeed(url)} onState={(s: any) => states.push({ ...s })} /> ); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); const last = states[states.length - 1]; expect(last.data).toEqual(mockData); expect(last.loading).toBe(false); expect(last.error).toBeUndefined(); expect(global.fetch).toHaveBeenCalledTimes(1); }); it('starts in loading state when url is provided', async () => { global.fetch = jest.fn( () => new Promise(() => { /* never resolves */ }) ); const url = uniqueUrl('loading'); const states: any[] = []; await act(async () => { render( useFetchFeed(url)} onState={(s: any) => states.push({ ...s })} /> ); }); expect(states[0].loading).toBe(true); expect(states[0].data).toBeUndefined(); }); it('handles HTTP error responses', async () => { global.fetch = jest.fn(() => mockErrorResponse(500)); const url = uniqueUrl('http-err'); const states: any[] = []; await act(async () => { render( useFetchFeed(url)} onState={(s: any) => states.push({ ...s })} /> ); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); const last = states[states.length - 1]; expect(last.error).toBe('HTTP 500'); expect(last.loading).toBe(false); expect(last.data).toBeUndefined(); }); it('handles network fetch errors', async () => { global.fetch = jest.fn(() => Promise.reject(new Error('Network error'))); const url = uniqueUrl('net-err'); const states: any[] = []; await act(async () => { render( useFetchFeed(url)} onState={(s: any) => states.push({ ...s })} /> ); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); const last = states[states.length - 1]; expect(last.error).toBe('Network error'); expect(last.loading).toBe(false); }); it('handles fetch errors without message', async () => { global.fetch = jest.fn(() => Promise.reject({})); const url = uniqueUrl('no-msg'); const states: any[] = []; await act(async () => { render( useFetchFeed(url)} onState={(s: any) => states.push({ ...s })} /> ); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); const last = states[states.length - 1]; expect(last.error).toBe('Fetch failed'); }); it('deduplicates concurrent requests to the same URL', async () => { const mockData = { result: 'shared' }; global.fetch = jest.fn(() => mockJsonResponse(mockData)); const url = uniqueUrl('dedup'); const states1: any[] = []; const states2: any[] = []; await act(async () => { render( <> useFetchFeed(url)} onState={(s: any) => states1.push({ ...s })} /> useFetchFeed(url)} onState={(s: any) => states2.push({ ...s })} /> ); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); // Only one fetch call despite two subscribers expect(global.fetch).toHaveBeenCalledTimes(1); expect(states1[states1.length - 1].data).toEqual(mockData); expect(states2[states2.length - 1].data).toEqual(mockData); }); it('polls at the given interval', async () => { const mockData = { tick: 1 }; global.fetch = jest.fn(() => mockJsonResponse(mockData)); const url = uniqueUrl('poll'); const states: any[] = []; await act(async () => { render( useFetchFeed(url, { pollingInterval: 5000 })} onState={(s: any) => states.push({ ...s })} /> ); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); expect(global.fetch).toHaveBeenCalledTimes(1); // Advance timers to trigger poll await act(async () => { jest.advanceTimersByTime(5000); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); expect(global.fetch).toHaveBeenCalledTimes(2); // Another poll await act(async () => { jest.advanceTimersByTime(5000); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); expect(global.fetch).toHaveBeenCalledTimes(3); }); it('stops polling when component unmounts', async () => { const mockData = { data: 'test' }; global.fetch = jest.fn(() => mockJsonResponse(mockData)); const url = uniqueUrl('stop-poll'); const states: any[] = []; let result: any; await act(async () => { result = render( useFetchFeed(url, { pollingInterval: 5000 })} onState={(s: any) => states.push({ ...s })} /> ); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); expect(global.fetch).toHaveBeenCalledTimes(1); // Unmount to trigger cleanup await act(async () => { result.unmount(); }); // Advance timers — no more fetches should happen await act(async () => { jest.advanceTimersByTime(15000); await Promise.resolve(); }); expect(global.fetch).toHaveBeenCalledTimes(1); }); it('does not poll when pollingInterval is 0', async () => { const mockData = { data: 'test' }; global.fetch = jest.fn(() => mockJsonResponse(mockData)); const url = uniqueUrl('nopoll'); await act(async () => { render( useFetchFeed(url, { pollingInterval: 0 })} onState={() => { /* noop */ }} /> ); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); expect(global.fetch).toHaveBeenCalledTimes(1); await act(async () => { jest.advanceTimersByTime(10000); await Promise.resolve(); }); expect(global.fetch).toHaveBeenCalledTimes(1); }); it('aborts in-flight request on unmount', async () => { let aborted = false; global.fetch = jest.fn((...args: any[]) => { const opts = args[1] as RequestInit | undefined; if (opts && opts.signal) { opts.signal.addEventListener('abort', () => { aborted = true; }); } return new Promise(() => { /* never resolves */ }); }); const url = uniqueUrl('abort'); let result: any; await act(async () => { result = render( useFetchFeed(url)} onState={() => { /* noop */ }} /> ); }); await act(async () => { result.unmount(); }); expect(aborted).toBe(true); }); it('uses shortest polling interval when multiple subscribers', async () => { const mockData = { data: 'test' }; global.fetch = jest.fn(() => mockJsonResponse(mockData)); const url = uniqueUrl('shortest'); await act(async () => { render( <> useFetchFeed(url, { pollingInterval: 10000 })} onState={() => { /* noop */ }} /> useFetchFeed(url, { pollingInterval: 3000 })} onState={() => { /* noop */ }} /> ); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); // Advance by 3 seconds — should trigger poll (shorter interval wins) await act(async () => { jest.advanceTimersByTime(3000); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); // 1 initial + 1 poll = 2 expect(global.fetch).toHaveBeenCalledTimes(2); }); it('stops polling when the only polling subscriber unsubscribes', async () => { const mockData = { data: 'test' }; global.fetch = jest.fn(() => mockJsonResponse(mockData)); const url = uniqueUrl('unsub-poll'); // Wrapper that lets us toggle the polling subscriber on/off. const Wrapper: React.FC<{ showPoller: boolean }> = ({ showPoller }) => ( <> {/* Non-polling subscriber — always mounted */} useFetchFeed(url)} onState={() => { /* noop */ }} /> {/* Polling subscriber — conditionally mounted */} {showPoller && ( useFetchFeed(url, { pollingInterval: 5000 })} onState={() => { /* noop */ }} /> )} ); let result: any; await act(async () => { result = render(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); expect(global.fetch).toHaveBeenCalledTimes(1); // Confirm polling is active. await act(async () => { jest.advanceTimersByTime(5000); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); expect(global.fetch).toHaveBeenCalledTimes(2); // Unmount the polling subscriber. await act(async () => { result.rerender(); }); // Advance timers — no more fetches should happen. await act(async () => { jest.advanceTimersByTime(15000); await Promise.resolve(); await Promise.resolve(); }); expect(global.fetch).toHaveBeenCalledTimes(2); }); });