a7e668ced8
- Add fetch plugin API (definePlugin, plugins) with type-level option flow. - Migrate to eslint flat config and composite tsconfig.
620 lines
20 KiB
TypeScript
620 lines
20 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import type { Fetch } from './types';
|
|
import { FetchError } from './error';
|
|
import { createFetch } from './fetch';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function makeFetchMock(
|
|
body: unknown = { ok: true },
|
|
init: ResponseInit = { status: 200 },
|
|
contentType = 'application/json',
|
|
) {
|
|
return vi.fn<Fetch>().mockResolvedValue(
|
|
new Response(typeof body === 'string' ? body : JSON.stringify(body), {
|
|
...init,
|
|
headers: { 'content-type': contentType, ...init.headers },
|
|
}),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Basic fetch
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('createFetch — basic', () => {
|
|
it('returns parsed JSON body', async () => {
|
|
const fetchMock = makeFetchMock({ id: 1 });
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
|
|
const data = await $fetch<{ id: number }>('https://api.example.com/user');
|
|
|
|
expect(data).toEqual({ id: 1 });
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('passes options through to the underlying fetch', async () => {
|
|
const fetchMock = makeFetchMock({ done: true });
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
|
|
await $fetch('https://api.example.com/task', {
|
|
method: 'POST',
|
|
headers: { 'x-token': 'abc' },
|
|
});
|
|
|
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect((init.headers as Headers).get('x-token')).toBe('abc');
|
|
expect(init.method).toBe('POST');
|
|
});
|
|
|
|
it('uppercases the HTTP method', async () => {
|
|
const fetchMock = makeFetchMock({});
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
|
|
await $fetch('https://api.example.com', { method: 'post' });
|
|
|
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(init.method).toBe('POST');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// raw
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('$fetch.raw', () => {
|
|
it('returns a Response with _data', async () => {
|
|
const fetchMock = makeFetchMock({ value: 42 });
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
|
|
const response = await $fetch.raw<{ value: number }>('https://api.example.com');
|
|
|
|
expect(response).toBeInstanceOf(Response);
|
|
expect(response._data).toEqual({ value: 42 });
|
|
expect(response.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Method shortcuts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('method shortcuts', () => {
|
|
it('$fetch.get sends a GET request', async () => {
|
|
const fetchMock = makeFetchMock({});
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
await $fetch.get('https://api.example.com/items');
|
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(init.method).toBe('GET');
|
|
});
|
|
|
|
it('$fetch.post sends a POST request', async () => {
|
|
const fetchMock = makeFetchMock({});
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
await $fetch.post('https://api.example.com/items', { body: { name: 'x' } });
|
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(init.method).toBe('POST');
|
|
});
|
|
|
|
it('$fetch.put sends a PUT request', async () => {
|
|
const fetchMock = makeFetchMock({});
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
await $fetch.put('https://api.example.com/items/1', { body: { name: 'y' } });
|
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(init.method).toBe('PUT');
|
|
});
|
|
|
|
it('$fetch.patch sends a PATCH request', async () => {
|
|
const fetchMock = makeFetchMock({});
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
await $fetch.patch('https://api.example.com/items/1', { body: { name: 'z' } });
|
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(init.method).toBe('PATCH');
|
|
});
|
|
|
|
it('$fetch.delete sends a DELETE request', async () => {
|
|
const fetchMock = makeFetchMock({});
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
await $fetch.delete('https://api.example.com/items/1');
|
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(init.method).toBe('DELETE');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// baseURL
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('baseURL', () => {
|
|
it('prepends baseURL to a relative path', async () => {
|
|
const fetchMock = makeFetchMock({});
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
|
|
await $fetch('/users', { baseURL: 'https://api.example.com/v1' });
|
|
|
|
const [url] = fetchMock.mock.calls[0] as [string];
|
|
expect(url).toBe('https://api.example.com/v1/users');
|
|
});
|
|
|
|
it('inherits baseURL from create() defaults', async () => {
|
|
const fetchMock = makeFetchMock({});
|
|
const api = createFetch({ fetch: fetchMock }).create({ baseURL: 'https://api.example.com' });
|
|
|
|
await api('/health');
|
|
|
|
const [url] = fetchMock.mock.calls[0] as [string];
|
|
expect(url).toBe('https://api.example.com/health');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Query params
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('query params', () => {
|
|
it('appends query to the request URL', async () => {
|
|
const fetchMock = makeFetchMock([]);
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
|
|
await $fetch('https://api.example.com/items', { query: { page: 2, limit: 10 } });
|
|
|
|
const [url] = fetchMock.mock.calls[0] as [string];
|
|
expect(url).toContain('page=2');
|
|
expect(url).toContain('limit=10');
|
|
});
|
|
|
|
it('merges default query with per-request query', async () => {
|
|
const fetchMock = makeFetchMock([]);
|
|
const $fetch = createFetch({ fetch: fetchMock }).create({
|
|
baseURL: 'https://api.example.com',
|
|
query: { version: 2 },
|
|
});
|
|
|
|
await $fetch('/items', { query: { page: 1 } });
|
|
|
|
const [url] = fetchMock.mock.calls[0] as [string];
|
|
expect(url).toContain('version=2');
|
|
expect(url).toContain('page=1');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// JSON body serialisation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('JSON body serialisation', () => {
|
|
it('serialises plain objects and sets content-type to application/json', async () => {
|
|
const fetchMock = makeFetchMock({ ok: true });
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
|
|
await $fetch('https://api.example.com/users', {
|
|
method: 'POST',
|
|
body: { name: 'Alice' },
|
|
});
|
|
|
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(init.body).toBe('{"name":"Alice"}');
|
|
expect((init.headers as Headers).get('content-type')).toBe('application/json');
|
|
});
|
|
|
|
it('respects a pre-set content-type header', async () => {
|
|
const fetchMock = makeFetchMock({ ok: true });
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
|
|
await $fetch('https://api.example.com/form', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
body: { key: 'value' },
|
|
});
|
|
|
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(init.body).toBe('key=value');
|
|
});
|
|
|
|
it('passes a raw string body through without forcing a JSON content-type', async () => {
|
|
const fetchMock = makeFetchMock({ ok: true });
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
|
|
await $fetch('https://api.example.com/raw', {
|
|
method: 'POST',
|
|
body: 'plain text payload',
|
|
});
|
|
|
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(init.body).toBe('plain text payload');
|
|
expect((init.headers as Headers).get('content-type')).toBeNull();
|
|
expect((init.headers as Headers).get('accept')).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Error handling
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('error handling', () => {
|
|
it('throws FetchError on 4xx response', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response('{"error":"not found"}', {
|
|
status: 404,
|
|
statusText: 'Not Found',
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
);
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
|
|
await expect($fetch('https://api.example.com/missing')).rejects.toBeInstanceOf(FetchError);
|
|
});
|
|
|
|
it('throws FetchError on 5xx response', async () => {
|
|
// Use mockImplementation so each retry attempt gets a fresh Response (body not yet read)
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockImplementation(async () => new Response('Internal Server Error', { status: 500, statusText: 'Internal Server Error' }));
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
|
|
await expect($fetch('https://api.example.com/crash')).rejects.toThrow(FetchError);
|
|
});
|
|
|
|
it('does not throw when ignoreResponseError is true', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response('{"error":"bad request"}', {
|
|
status: 400,
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
);
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
|
|
await expect(
|
|
$fetch('https://api.example.com/bad', { ignoreResponseError: true }),
|
|
).resolves.toEqual({ error: 'bad request' });
|
|
});
|
|
|
|
it('throws FetchError on network error', async () => {
|
|
const fetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
|
|
await expect($fetch('https://api.example.com/offline')).rejects.toBeInstanceOf(FetchError);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Retry
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('retry', () => {
|
|
it('retries once on 500 by default for GET', async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(new Response('error', { status: 500 }))
|
|
.mockResolvedValueOnce(
|
|
new Response('{"ok":true}', {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
);
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
|
|
const data = await $fetch('https://api.example.com/flaky');
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(data).toEqual({ ok: true });
|
|
});
|
|
|
|
it('does not retry POST by default', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(new Response('error', { status: 500 }));
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
|
|
await expect(
|
|
$fetch('https://api.example.com/task', { method: 'POST' }),
|
|
).rejects.toBeInstanceOf(FetchError);
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('respects retry: false', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(new Response('error', { status: 503 }));
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
|
|
await expect(
|
|
$fetch('https://api.example.com/flaky', { retry: false }),
|
|
).rejects.toBeInstanceOf(FetchError);
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('respects custom retryStatusCodes', async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(new Response('', { status: 418 }))
|
|
.mockResolvedValueOnce(
|
|
new Response('{"ok":true}', {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
);
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
|
|
const data = await $fetch('https://api.example.com/teapot', {
|
|
retryStatusCodes: [418],
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(data).toEqual({ ok: true });
|
|
});
|
|
|
|
it('does not retry a user-initiated abort', async () => {
|
|
const controller = new AbortController();
|
|
const fetchMock = vi.fn().mockImplementation((_url: string, init: RequestInit) =>
|
|
new Promise((_resolve, reject) => {
|
|
const signal = init.signal as AbortSignal;
|
|
signal.addEventListener('abort', () => reject(signal.reason));
|
|
}),
|
|
);
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
|
|
const promise = $fetch('https://api.example.com/cancel', {
|
|
signal: controller.signal,
|
|
retry: 3,
|
|
});
|
|
controller.abort();
|
|
|
|
await expect(promise).rejects.toBeInstanceOf(FetchError);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('clears a stale error on a successful retry before onResponse runs', async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new TypeError('network down'))
|
|
.mockResolvedValueOnce(
|
|
new Response('{"ok":true}', {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
);
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
|
|
let errorInResponseHook: unknown = 'unset';
|
|
const data = await $fetch('https://api.example.com/flaky', {
|
|
onResponse: (ctx) => {
|
|
errorInResponseHook = ctx.error;
|
|
},
|
|
});
|
|
|
|
expect(data).toEqual({ ok: true });
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(errorInResponseHook).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lifecycle hooks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('lifecycle hooks', () => {
|
|
it('calls onRequest before sending', async () => {
|
|
const fetchMock = makeFetchMock({});
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
const calls: string[] = [];
|
|
|
|
await $fetch('https://api.example.com', {
|
|
onRequest: () => {
|
|
calls.push('request');
|
|
},
|
|
});
|
|
|
|
expect(calls).toContain('request');
|
|
expect(calls.indexOf('request')).toBeLessThan(1);
|
|
});
|
|
|
|
it('calls onResponse after a successful response', async () => {
|
|
const fetchMock = makeFetchMock({ data: 1 });
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
const calls: string[] = [];
|
|
|
|
await $fetch('https://api.example.com', {
|
|
onResponse: () => {
|
|
calls.push('response');
|
|
},
|
|
});
|
|
|
|
expect(calls).toContain('response');
|
|
});
|
|
|
|
it('calls onResponseError for 4xx responses', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(new Response('', { status: 401 }));
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
const calls: string[] = [];
|
|
|
|
await expect(
|
|
$fetch('https://api.example.com/protected', {
|
|
retry: false,
|
|
onResponseError: () => {
|
|
calls.push('responseError');
|
|
},
|
|
}),
|
|
).rejects.toBeInstanceOf(FetchError);
|
|
|
|
expect(calls).toContain('responseError');
|
|
});
|
|
|
|
it('calls onRequestError on network failure', async () => {
|
|
const fetchMock = vi.fn().mockRejectedValue(new TypeError('Network error'));
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
const calls: string[] = [];
|
|
|
|
await expect(
|
|
$fetch('https://api.example.com/offline', {
|
|
retry: false,
|
|
onRequestError: () => {
|
|
calls.push('requestError');
|
|
},
|
|
}),
|
|
).rejects.toBeInstanceOf(FetchError);
|
|
|
|
expect(calls).toContain('requestError');
|
|
});
|
|
|
|
it('supports multiple hooks as an array', async () => {
|
|
const fetchMock = makeFetchMock({});
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
const calls: number[] = [];
|
|
|
|
await $fetch('https://api.example.com', {
|
|
onRequest: [
|
|
() => {
|
|
calls.push(1);
|
|
},
|
|
() => {
|
|
calls.push(2);
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(calls).toEqual([1, 2]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// create / extend
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('create and extend', () => {
|
|
it('creates a new instance with merged defaults', async () => {
|
|
const fetchMock = makeFetchMock({});
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
const api = $fetch.create({ baseURL: 'https://api.example.com' });
|
|
|
|
await api('/ping');
|
|
|
|
const [url] = fetchMock.mock.calls[0] as [string];
|
|
expect(url).toBe('https://api.example.com/ping');
|
|
});
|
|
|
|
it('extend is an alias for create', async () => {
|
|
const fetchMock = makeFetchMock({});
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
const api = $fetch.extend({ baseURL: 'https://api.example.com' });
|
|
|
|
await api('/ping');
|
|
|
|
const [url] = fetchMock.mock.calls[0] as [string];
|
|
expect(url).toBe('https://api.example.com/ping');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Response type variants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('response types', () => {
|
|
it('returns text when responseType is "text"', async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValue(new Response('hello world', { headers: { 'content-type': 'text/plain' } }));
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
|
|
const text = await $fetch<string, 'text'>('https://api.example.com/text', {
|
|
responseType: 'text',
|
|
});
|
|
|
|
expect(text).toBe('hello world');
|
|
});
|
|
|
|
it('returns a Blob when responseType is "blob"', async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValue(new Response('binary', { headers: { 'content-type': 'image/png' } }));
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
|
|
const blob = await $fetch<Blob, 'blob'>('https://api.example.com/img', {
|
|
responseType: 'blob',
|
|
});
|
|
|
|
expect(blob).toBeInstanceOf(Blob);
|
|
});
|
|
|
|
it('uses a custom parseResponse function', async () => {
|
|
const fetchMock = vi
|
|
.fn<Fetch>()
|
|
.mockResolvedValue(
|
|
new Response('{"value":10}', { headers: { 'content-type': 'application/json' } }),
|
|
);
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
|
|
const data = await $fetch<{ value: number; custom: boolean }>('https://api.example.com/custom', {
|
|
parseResponse: text => ({ ...(JSON.parse(text) as { value: number }), custom: true }),
|
|
});
|
|
|
|
expect(data).toEqual({ value: 10, custom: true });
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Timeout
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('timeout', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('aborts a request that exceeds the timeout', async () => {
|
|
// fetchMock that never resolves until the signal fires
|
|
const fetchMock = vi.fn().mockImplementation((_url: string, init: RequestInit) => {
|
|
return new Promise((_resolve, reject) => {
|
|
(init.signal as AbortSignal).addEventListener('abort', () => {
|
|
reject(new DOMException('The operation was aborted.', 'AbortError'));
|
|
});
|
|
});
|
|
});
|
|
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
|
|
const promise = $fetch('https://api.example.com/slow', { timeout: 100, retry: false });
|
|
|
|
vi.advanceTimersByTime(200);
|
|
|
|
await expect(promise).rejects.toBeInstanceOf(FetchError);
|
|
});
|
|
|
|
it('uses a fresh, un-aborted timeout signal on each retry attempt', async () => {
|
|
let attempt = 0;
|
|
const fetchMock = vi.fn().mockImplementation((_url: string, init: RequestInit) => {
|
|
attempt += 1;
|
|
const signal = init.signal as AbortSignal;
|
|
|
|
// First attempt hangs until its own timeout fires.
|
|
if (attempt === 1) {
|
|
return new Promise((_resolve, reject) => {
|
|
signal.addEventListener('abort', () => reject(signal.reason));
|
|
});
|
|
}
|
|
|
|
// Retry must receive a brand-new signal, not the already-aborted one.
|
|
expect(signal.aborted).toBe(false);
|
|
return Promise.resolve(
|
|
new Response('{"ok":true}', {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
);
|
|
});
|
|
|
|
const $fetch = createFetch({ fetch: fetchMock });
|
|
const promise = $fetch('https://api.example.com/slow', { timeout: 100 });
|
|
|
|
// Fire attempt-1 timeout and let the retry proceed to attempt 2.
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
|
|
await expect(promise).resolves.toEqual({ ok: true });
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|