a7e668ced8
- Add fetch plugin API (definePlugin, plugins) with type-level option flow. - Migrate to eslint flat config and composite tsconfig.
270 lines
8.4 KiB
TypeScript
270 lines
8.4 KiB
TypeScript
import {
|
|
buildURL,
|
|
callHooks,
|
|
detectResponseType,
|
|
isJSONSerializable,
|
|
isPayloadMethod,
|
|
joinURL,
|
|
resolveFetchOptions,
|
|
} from './utils';
|
|
import { describe, expect, it } from 'vitest';
|
|
import type { FetchContext } from './types';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// isPayloadMethod
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('isPayloadMethod', () => {
|
|
it('returns true for payload methods', () => {
|
|
expect(isPayloadMethod('POST')).toBe(true);
|
|
expect(isPayloadMethod('PUT')).toBe(true);
|
|
expect(isPayloadMethod('PATCH')).toBe(true);
|
|
expect(isPayloadMethod('DELETE')).toBe(true);
|
|
});
|
|
|
|
it('returns false for non-payload methods', () => {
|
|
expect(isPayloadMethod('GET')).toBe(false);
|
|
expect(isPayloadMethod('HEAD')).toBe(false);
|
|
expect(isPayloadMethod('OPTIONS')).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// isJSONSerializable
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('isJSONSerializable', () => {
|
|
it('returns false for undefined', () => {
|
|
expect(isJSONSerializable(undefined)).toBe(false);
|
|
});
|
|
|
|
it('returns true for primitives', () => {
|
|
expect(isJSONSerializable('hello')).toBe(true);
|
|
expect(isJSONSerializable(42)).toBe(true);
|
|
expect(isJSONSerializable(true)).toBe(true);
|
|
expect(isJSONSerializable(null)).toBe(true);
|
|
});
|
|
|
|
it('returns false for functions, symbols, bigints', () => {
|
|
expect(isJSONSerializable(() => {})).toBe(false);
|
|
expect(isJSONSerializable(Symbol('x'))).toBe(false);
|
|
expect(isJSONSerializable(42n)).toBe(false);
|
|
});
|
|
|
|
it('returns true for plain arrays', () => {
|
|
expect(isJSONSerializable([1, 2, 3])).toBe(true);
|
|
});
|
|
|
|
it('returns false for ArrayBuffer-like values', () => {
|
|
expect(isJSONSerializable(new Uint8Array([1, 2]))).toBe(false);
|
|
});
|
|
|
|
it('returns false for FormData and URLSearchParams', () => {
|
|
expect(isJSONSerializable(new FormData())).toBe(false);
|
|
expect(isJSONSerializable(new URLSearchParams())).toBe(false);
|
|
});
|
|
|
|
it('returns true for plain objects', () => {
|
|
expect(isJSONSerializable({ a: 1 })).toBe(true);
|
|
});
|
|
|
|
it('returns true for objects with toJSON', () => {
|
|
expect(isJSONSerializable({ toJSON: () => ({}) })).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// detectResponseType
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('detectResponseType', () => {
|
|
it('defaults to json when content-type is empty', () => {
|
|
expect(detectResponseType('')).toBe('json');
|
|
expect(detectResponseType()).toBe('json');
|
|
});
|
|
|
|
it('detects json content types', () => {
|
|
expect(detectResponseType('application/json')).toBe('json');
|
|
expect(detectResponseType('application/json; charset=utf-8')).toBe('json');
|
|
expect(detectResponseType('application/vnd.api+json')).toBe('json');
|
|
});
|
|
|
|
it('detects event-stream as stream', () => {
|
|
expect(detectResponseType('text/event-stream')).toBe('stream');
|
|
});
|
|
|
|
it('detects text content types', () => {
|
|
expect(detectResponseType('text/plain')).toBe('text');
|
|
expect(detectResponseType('text/html')).toBe('text');
|
|
expect(detectResponseType('application/xml')).toBe('text');
|
|
});
|
|
|
|
it('falls back to blob for binary types', () => {
|
|
expect(detectResponseType('image/png')).toBe('blob');
|
|
expect(detectResponseType('application/octet-stream')).toBe('blob');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// buildURL
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('buildURL', () => {
|
|
it('appends query params to a clean URL', () => {
|
|
expect(buildURL('https://api.example.com', { page: 1, limit: 20 })).toBe(
|
|
'https://api.example.com?page=1&limit=20',
|
|
);
|
|
});
|
|
|
|
it('appends to an existing query string with &', () => {
|
|
expect(buildURL('https://api.example.com?foo=bar', { baz: 'qux' })).toBe(
|
|
'https://api.example.com?foo=bar&baz=qux',
|
|
);
|
|
});
|
|
|
|
it('omits null and undefined values', () => {
|
|
expect(buildURL('https://api.example.com', { a: null, b: undefined, c: 'keep' })).toBe(
|
|
'https://api.example.com?c=keep',
|
|
);
|
|
});
|
|
|
|
it('returns the URL unchanged when all params are omitted', () => {
|
|
expect(buildURL('https://api.example.com', { a: null })).toBe('https://api.example.com');
|
|
});
|
|
|
|
it('inserts the query string before a fragment', () => {
|
|
expect(buildURL('https://api.example.com/p#section', { a: 1 })).toBe(
|
|
'https://api.example.com/p?a=1#section',
|
|
);
|
|
});
|
|
|
|
it('appends to an existing query string before a fragment', () => {
|
|
expect(buildURL('https://api.example.com/p?foo=bar#section', { baz: 'qux' })).toBe(
|
|
'https://api.example.com/p?foo=bar&baz=qux#section',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// joinURL
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('joinURL', () => {
|
|
it('joins base and path correctly', () => {
|
|
expect(joinURL('https://api.example.com/v1', '/users')).toBe(
|
|
'https://api.example.com/v1/users',
|
|
);
|
|
});
|
|
|
|
it('does not double slashes', () => {
|
|
expect(joinURL('https://api.example.com/v1/', '/users')).toBe(
|
|
'https://api.example.com/v1/users',
|
|
);
|
|
});
|
|
|
|
it('adds a slash when neither side has one', () => {
|
|
expect(joinURL('https://api.example.com/v1', 'users')).toBe(
|
|
'https://api.example.com/v1/users',
|
|
);
|
|
});
|
|
|
|
it('returns base when path is empty', () => {
|
|
expect(joinURL('https://api.example.com', '')).toBe('https://api.example.com');
|
|
});
|
|
|
|
it('returns base when path is "/"', () => {
|
|
expect(joinURL('https://api.example.com', '/')).toBe('https://api.example.com');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// callHooks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('callHooks', () => {
|
|
function makeCtx(): FetchContext {
|
|
return {
|
|
request: 'https://example.com',
|
|
options: { headers: new Headers() },
|
|
response: undefined,
|
|
error: undefined,
|
|
} as FetchContext;
|
|
}
|
|
|
|
it('does nothing when hooks is undefined', async () => {
|
|
await expect(callHooks(makeCtx(), undefined)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('calls a single hook', async () => {
|
|
const calls: number[] = [];
|
|
await callHooks(makeCtx(), () => {
|
|
calls.push(1);
|
|
});
|
|
expect(calls).toEqual([1]);
|
|
});
|
|
|
|
it('calls an array of hooks in order', async () => {
|
|
const calls: number[] = [];
|
|
await callHooks(makeCtx(), [
|
|
() => { calls.push(1); },
|
|
() => { calls.push(2); },
|
|
() => { calls.push(3); },
|
|
]);
|
|
expect(calls).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
it('awaits async hooks', async () => {
|
|
const calls: number[] = [];
|
|
await callHooks(makeCtx(), [
|
|
async () => {
|
|
await Promise.resolve();
|
|
calls.push(1);
|
|
},
|
|
() => {
|
|
calls.push(2);
|
|
},
|
|
]);
|
|
expect(calls).toEqual([1, 2]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// resolveFetchOptions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('resolveFetchOptions', () => {
|
|
it('returns an object with a Headers instance', () => {
|
|
const resolved = resolveFetchOptions('https://example.com', undefined, undefined);
|
|
expect(resolved.headers).toBeInstanceOf(Headers);
|
|
});
|
|
|
|
it('merges input and default headers (input wins)', () => {
|
|
const resolved = resolveFetchOptions(
|
|
'https://example.com',
|
|
{ headers: { 'x-custom': 'input' } },
|
|
{ headers: { 'x-custom': 'default', 'x-default-only': 'yes' } },
|
|
);
|
|
expect(resolved.headers.get('x-custom')).toBe('input');
|
|
expect(resolved.headers.get('x-default-only')).toBe('yes');
|
|
});
|
|
|
|
it('merges query params from defaults and input', () => {
|
|
const resolved = resolveFetchOptions(
|
|
'https://example.com',
|
|
{ query: { a: '1' } },
|
|
{ query: { b: '2' } },
|
|
);
|
|
expect(resolved.query).toEqual({ a: '1', b: '2' });
|
|
});
|
|
|
|
it('merges params alias into query', () => {
|
|
const resolved = resolveFetchOptions(
|
|
'https://example.com',
|
|
{ params: { p: '10' } },
|
|
undefined,
|
|
);
|
|
expect(resolved.query).toEqual({ p: '10' });
|
|
expect(resolved.params).toEqual({ p: '10' });
|
|
});
|
|
});
|