The AbortController is correctness, not economy. Without it a slow response for 'st' can land after the fast one for 'st marys' and replace a correct list with a stale one — the classic autosuggest race. No cache: 'no-store', unlike the compare modal's search. This is the one endpoint where prefix queries repeat most across users, so discarding the browser cache and the backend's ETag 304s would be throwing away the cheapest win available. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
74 lines
2.7 KiB
TypeScript
74 lines
2.7 KiB
TypeScript
import { renderHook, act, waitFor } from '@testing-library/react';
|
|
import { useSchoolSuggest } from '@/hooks/useSchoolSuggest';
|
|
|
|
const realFetch = global.fetch;
|
|
|
|
function mockFetch(rows: unknown[], delayMs = 0) {
|
|
global.fetch = jest.fn(async (_url: unknown, init?: { signal?: AbortSignal }) => {
|
|
if (delayMs) {
|
|
await new Promise((resolve, reject) => {
|
|
const t = setTimeout(resolve, delayMs);
|
|
init?.signal?.addEventListener('abort', () => {
|
|
clearTimeout(t);
|
|
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
|
|
});
|
|
});
|
|
}
|
|
return { ok: true, json: async () => ({ suggestions: rows }) };
|
|
}) as unknown as typeof fetch;
|
|
}
|
|
|
|
const ROW = {
|
|
urn: 1, school_name: 'Brecknock Primary School', local_authority: 'Camden',
|
|
postcode: 'NW1 1AA', phase: 'Primary', school_type: 'Community school',
|
|
};
|
|
|
|
describe('useSchoolSuggest', () => {
|
|
beforeEach(() => { jest.useFakeTimers(); });
|
|
afterEach(() => { jest.useRealTimers(); global.fetch = realFetch; });
|
|
|
|
it('does not fetch below the minimum query length', () => {
|
|
mockFetch([ROW]);
|
|
renderHook(() => useSchoolSuggest('b', true));
|
|
act(() => { jest.advanceTimersByTime(500); });
|
|
expect(global.fetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not fetch at all when disabled', () => {
|
|
// The flag being off must mean no request, not a hidden dropdown.
|
|
mockFetch([ROW]);
|
|
renderHook(() => useSchoolSuggest('brecknock', false));
|
|
act(() => { jest.advanceTimersByTime(500); });
|
|
expect(global.fetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('debounces rather than firing per keystroke', () => {
|
|
mockFetch([ROW]);
|
|
const { rerender } = renderHook(
|
|
({ q }) => useSchoolSuggest(q, true), { initialProps: { q: 'br' } });
|
|
rerender({ q: 'bre' });
|
|
rerender({ q: 'brec' });
|
|
act(() => { jest.advanceTimersByTime(199); });
|
|
expect(global.fetch).not.toHaveBeenCalled();
|
|
act(() => { jest.advanceTimersByTime(2); });
|
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('opens with results once they arrive', async () => {
|
|
mockFetch([ROW]);
|
|
const { result } = renderHook(() => useSchoolSuggest('brecknock', true));
|
|
act(() => { jest.advanceTimersByTime(200); });
|
|
await waitFor(() => expect(result.current.suggestions).toHaveLength(1));
|
|
expect(result.current.open).toBe(true);
|
|
});
|
|
|
|
it('close() hides the list without clearing the query', async () => {
|
|
mockFetch([ROW]);
|
|
const { result } = renderHook(() => useSchoolSuggest('brecknock', true));
|
|
act(() => { jest.advanceTimersByTime(200); });
|
|
await waitFor(() => expect(result.current.open).toBe(true));
|
|
act(() => { result.current.close(); });
|
|
expect(result.current.open).toBe(false);
|
|
});
|
|
});
|